go 零散笔记
2021-08-20 tech go 53 mins 8 图 18853 字
我从17年开始写go代码,到现在断断续续写了四年有余,其实比较惭愧,目前对go的认识非常浅薄。
究其原因,一个是我使用go的开发只是工作上粘合使用,每年写go代码的时间也不足1个月,基本上是在原有框架上做一些新功能的开发。得益于过去多年在laravel上的经验,看api文档和谷歌能力还是不错的,socket交互、orm、mongodb、k8s client-go等东西上手并不难,看半天基本上也就明白了如何使用。
二个是我工作中心更多是放在k8s这套系统以及偏网络方向上,虽然也是研发,更多是行业和架构层面的。
从今年下半年开始,我的工作重心转到了go的开发上来,而我个人也倾向于使用go作为我未来的主力开发语言。接下来这段时间我会记录更多关于 go 的基础知识。
这篇文章没什么重点,记录一些只言片语吧。
一、学习书籍
当遇到看不懂的内容时,有可能是作者的思考回路和我们的有差别。
不必纠结,跳过去,当看到同样内容不同作者的描述,你可能会豁然开朗。
入门:
入门时要注重理解go的设计理念和语言机制(Language Mechanics),
语言机制包括Go语言的句法、数据结构、解耦。
- 《Go 程序设计语言》——许式伟译(英文原版翻译的,感觉细看浪费时间,快速浏览/后期针对性溯源就行。)
- 《Go 语言编程》——许式伟(感觉适合基础入门,以补全理论概念铺垫为主,实战代码可以后期再看)
熟练:
熟练时要理解软件设计,研究并发,Go协程(Goroutine)、数据竞赛、多个channel和不用模式和用模式下的操作
- 《Go 语言学习笔记》——雨痕(适合实操,看完Go 语言编程赶紧来看这个,第二部分源码解析需要慢慢看)
- 《Go 专家编程》
高级:
了解基本单元测试、表测试、自测试等发测试方法,以及常见的标准等,还有各种包(Packages)。
更多选看:
- web-dev-golang-anti-textbook:在没有 Go 框架的情况下编写 web 应用程序。
- ardanlabs/gotraining
二、Go 概念只言片语
这一部分大多来自 《Go 语言编程》——许式伟
2. 1 一些网站:
- Go官网: http://golang.org
- Go 开发:https://github.com/golang/go
- N年没更新的wiki,可以瞧瞧:http://github.com/wonderfo/wonderfogo/wiki
2. 2 基础类型:
-
int/unit/string/bool
-
大整数big.Rat/浮点数fload/复数complex (math包)
-
string (strings包/strconv包/fmt包/utf8包/unicode包/regexp包)
-
字符类型rune
-
错误类型error
2. 3 组合类型:
-
指针
-
数组
-
slice切片
-
map(哈希表/字典)
-
通道channel
-
struct 结构体
-
interface接口
-
指定一组方法,抽象的,不可以实例化。接口的名字,默认以er结尾。接口可以嵌入。
-
空接口 interface{}, 可以表示任意值,相当于指向任意类型的指针。
-
2. 4 流程控制:
- 选择
- 条件语句 if else
- 选择语句 switch case/select
- break continue fallthrough
- 循环 for 和 range
- 跳转 goto
2. 5 函数调用:
- 大小写区分:
- 小写字母开头的函数只在本包内可见,大写字母开头的函数才能被其他包使用。
- 这个规则也适用于类型和变量的可见性。
-
函数可以像普通变量一样被传递或使用
-
不定参数:
func myfunc(args ...int)
func Printf(format string, args ...interface{})
-
多返回值:
func (file *File) Read(b []byte) (n int, err Error)
-
匿名函数/闭包:
- 闭包内可以引用父方法的变量
- 父方法为闭包提供绑定的计算环境(作用域)
-
错误处理:
-
type error interface { Error() string }
-
defer
- 先进后出的原则,延迟执行,一般用于文件、通道chan关闭、错误处理等。
- panic/recover
-
- init()和main()
- 动态函数
- 泛型函数
- 高阶函数
- 纯记忆函数
2. 6 类型系统:
2. 7 面向对象:
-
构造函数,以NewXXX 来命名,表示“构造函数”
func NewRect(x, y, width, height float64) *Rect { return &Rect{x, y, width, height} }
-
有继承,直接在struct引用父struct就ok了(匿名组合)。可以重写覆盖父方法。
-
方法/变量的可见性,用大小写表示public/private。
-
接口,隐式声明。
- 变量类型查询: v1.(type)
2. 8 并发编程:
使用场景:
实现方式:
协程:
goroutine:
-
go Add(1, 1)
-
并发通信
- 共享数据(c/c++,加锁)
- 消息通信(go)
-
channel是类型相关的,只能传递一种类型的值,这个类型需要在声明时指定。
-
var chanName chan ElementType // 定义 c := make(chan int, 1024) // 初始化,带缓冲区 ch <- value // 将一个数据写入(发送)至channel的语法 value := <-ch // 从channel中读取数据 // 不带缓冲区时,向channel写入数据和读取数据会导致程序阻塞,直到有其他goroutine从这个channel中读取数据/写入数据为止。 close(ch) //关闭channel
-
单向channel:
// 基于 ch4 ,通过类型转换初始化了两个单向channel:单向读的 ch5 和单向写的 ch6 。 ch4 := make(chan int) ch5 := <-chan int(ch4) // ch5就是一个单向的读取channel ch6 := chan<- int(ch4) // ch6 是一个单向的写入channel
-
demo:
-
高阶用法:
-
底层多核并行:还没支持?
-
出让时间片:Gosched()
-
同步锁:sync.Mutex 、 sync.RWMutex
-
多次只运行一次:once.Do()
-
代码示例:
-
-
2. 9 网络编程:
- Socket
- http
- rpc
- json
- net/http包
2. 10 安全编程:
- pki
- hash函数
- MD5
- SHA-1
- https
2.11 代码规范:
-
任何需要对外暴露的名字必须以大写字母开头、不需要对外暴露的则应该以小写字母开头
-
Go语言明确宣告了拥护骆驼命名法而排斥下划线法
-
代码块中,左花括号
{
必须跟在同一行 -
工程结构:
README LICENSE bin/ pkg/ src/
2. 12 工程构建与命令行:
命令行主要完成以下这几类工作:
-
代码格式化
-
代码质量分析和修复
-
单元测试与性能测试
-
创建以
_test
结尾的go文件,形如[^.]*_test.go
-
以 Test 和 Benchmark 为函数名前缀并以 *testing.T 为单一参数的函数。
func TestAdd1(t *testing.T) func BenchmarkAdd1(t *testing.T)
-
-
工程构建
-
代码文档的提取和展示
-
跨平台开发、编译
2. 13 高阶话题
-
反射(reflection)
-
多语言
- cgo
-
协程goroutine原理
-
标准库
go 标准库,导入使用unix风格。导入包的使用惯例,
pkg.item
。https://books.studygolang.com/The-Golang-Standard-Library-by-Example/
输入输出 (Input/Output) 文本 数据结构与算法 日期与时间 数学计算 文件系统 数据持久存储与交换 数据压缩与归档 测试 进程、线程与 goroutine 网络通信与互联网 (Internet) email 应用构建 与 debug 运行时特性 底层库介绍 同步 加解密
2. 14 其它
方法与函数的区别:
函数是指不属于任何结构体、类型的方法,也就是说函数是没有接收者的;
方法是有接收者的,我们说的方法要么是属于一个结构体的,要么属于一个新定义的类型的。
方法在定义的时候,会在func
和方法名之间增加一个参数,这个参数就是接收者,这样我们定义的这个方法就和接收者绑定在了一起,称之为这个接收者的方法。
type person struct {
name string
}
func (p person) String() string{
return "the person name is "+p.name
}
用户自定义类型,也应该实现Len()和Cap()方法。
Go语言的符号(symbol)一样,以大写字母开头的常量/函数在包外可见。
方法func
常量const(字面量)
变量var,赋予一内存块名字,该内存块保存特定的数据类型。可以匿名(返回值,等号左侧填_)
指针:保存了另一个变量内存地址的变量。
&
,取址操作符。
*
,解引用操作符。
如果一个函数/方法返回超过4/5个值,最好使用一个切片/指向结构体的指针来传递,成本较低。
三、Go 实操笔记
这一部分大多来自:《Go 语言学习笔记》——雨痕
-
运行时runtime、编译时Combile-time。
- 静态类型语言都需要编译,Go是静态类型语言,不能在运行时改变变量类型。
-
变量
- 使用关键字 var 定义变量,自动初始化为零值。
- 在函数内部,可用更简略的 “:=” 方式定义变量。
- 可一次定义多个变量。
- 特殊只写变量 “_“,用于忽略值占位。
-
常量
-
必须是编译期可确定的数字、字符串、布尔值。
-
如不提供类型和初始化值,那么值与上一常量相同。
-
iota是常量计数器,在定义枚举时很有用。
type AudioOutput int const ( OutMute AudioOutput = iota // 0 OutMono // 1 OutStereo // 2 _ _ OutSurround // 5 )
-
-
引用类型
- 包括 slice、map 和 channel。
- 内置函数 new 计算类型大小,为其分配零值内存,返回指针。
- make 会被编译器翻译成具体的创建函数,由其分配内存和初始化成员结构,返回对象而非指针。
-
字符串
-
字符串是不可变值类型,内部用指针指向 UTF-8 字节数组。
-
默认值是空字符串 ““。
-
用索引号访问某字节,如 s[i]。
-
不能用索引号获取字节元素指针,&s[i] 非法。
-
不可变类型,无法修改字节数组。
-
字节数组尾部不包含 NULL。
-
使用 “`” 定义不做转义处理的原始字符串,支支持跨行行。
-
连接跨行字符串时,”+” 必须在上一行末尾,否则编译错误。
-
支持用两个索引号返回子串。子串依然指向原字节数组,仅修改了指针和长度属性。
s := "Hello, World!" s1 := s[:5] // Hello s2 := s[7:] // World! s3 := s[1:5] // ello
-
rune 是 int32 的别名,几乎在所有方面等同于int32,用于区分字符值和整数值。
-
golang 中的字符有两种,uint8(byte)代表ASCII的一个字符,rune代表一个utf-8字符。
-
修改字符串,可先将其转换成 []rune 或 []byte,完成后再转换为 string.无论哪种转换,都会重新分配内存,并复制字节数组。有汉字等需要utf8支持的就用rune,没汉字随意。
func main() { s := "abc汉字" for i := 0; i < len(s); i++ { // byte fmt.Printf("%c,", s[i]) } fmt.Println() for _, r := range s { // rune fmt.Printf("%c,", r) } }
-
-
指针
-
默认值 nil,没有 NULL 常量。
-
操作符 “&” 取变量地址,”*” 透过指针访问⺫目目标对象。
-
不支持指针运算,不支持 “->” 运算符,直接用 “.” 访问目标成员。
func main() { type data struct{ a int } var d = data{1234} var p *data p = &d fmt.Printf("%p, %v\n", p, p.a) // 直接用指针访问目标对象成员,无须转换。 }
-
可以在 unsafe.Pointer 和任意类型指针间进行转换。
func main() { x := 0x12345678 p := unsafe.Pointer(&x) // *int -> Pointer n := (*[4]byte)(p) // Pointer -> *[4]byte for i := 0; i < len(n); i++ { fmt.Printf("%X ", n[i]) } } // 78 56 34 12
-
将 Pointer 转换成 uintptr,可变相实现指针运算。
func main() { d := struct { s string x int }{"abc", 100} p := uintptr(unsafe.Pointer(&d)) // *struct -> Pointer -> uintptr p += unsafe.Offsetof(d.x) // uintptr + offset p2 := unsafe.Pointer(p) // uintptr -> Pointer px := (*int)(p2) // Pointer -> *int *px = 200 // d.x = 200 fmt.Printf("%#v\n", d) } // struct { s string; x int }{s:"abc", x:200}
-
-
自定义类型
- 可用 type 在全局或函数内定义新类型。
type bigint int64
- 显示转换。
x := 1234
var b bigint = bigint(x)
- 可用 type 在全局或函数内定义新类型。
-
保留字
break default func interface select case defer go map struct chan else goto package switch const fallthrough if range type continue for import return var
-
位运算
-
循环
-
for,支持初始化语句。
s := "abcd" for i, n := 0, length(s); i < n; i++ { println(i, s[i]) }
-
range,range 会复制对象。
for k, v := range m { println(k, v) }
-
switch,可省略break,表达式可以任意类型,不限于常量,需要继续下一支支,使用 fallthrough
-
省略条件表达式,switch可当 if…else if…else 使用
switch i := x[2]; { // 带初始化语句 case i > 0: println("a") case i < 0: println("b") default: println("c") }
-
break 可用用于 for、switch、select,而 continue 仅能用于 for 循环。
-
支持在函数内 goto 跳转。标签名区分大小写
-
-
函数
-
不支持 嵌套 (nested)、重载 (overload) 和 默认参数 (default parameter)。
-
无需声明原型 支持不定长变参 支持多返回值 支持命名返回参数 支持匿名函数和闭包
-
有返回值的函数,必须有明确的终止语句,否则会引发编译错误。
-
变参,本质上就是 slice。只能有一个,且必须是最后一个。
-
使用 slice 对象做变参时,必须展开。
func main() { s := []int{1, 2, 3} println(test("sum: %d", s...)) }
-
多返回值可直接作为其他函数调用实参。
-
使用用 slice 对象做变参时,必须展开。
-
命名返回参数可被同名局部变量遮蔽,此时需要显式返回。
-
匿名函数可赋值给变量,做为结构字段,或者在 channel 里传送。
-
延迟执行defer
-
在 defer 归属的函数即将返回时,将延迟处理的语句按 defer 的逆序进行执行
func test() { defer func() { fmt.Println(recover()) }() defer func() { panic("defer panic") }() panic("test panic") } func main() { test() }
-
滥用 defer 可能会导致性能问题,尤其是在一个 “大循环” 里。
-
如果需要保护代码片段,可将代码块重构成匿名函数,如此可确保后续代码被执行。
-
-
-
数组
-
数组是值类型,赋值和传参会复制整个数组,而不是指针。
-
内置函数 len 和 cap 都返回数组长度 (元素数量)。
-
指针数组 [n]*T,数组指针 *[n]T。
-
值拷贝会造成性能问题,请使用 slice,或数组指针。
a := [3]int{1, 2} // 未初始化元素值为 0。 b := [...]int{1, 2, 3, 4} // 通过初始化值确定数组⻓长度。 c := [5]int{2: 100, 4:200} // 使用用索引号初始化元素。
-
-
Slice
-
初始化和数组很像,不用声明长度
s1 := []int{0, 1, 2, 3, 8: 100} // 通过初始化表达式构造,可使用用索引号。 fmt.Println(s1, len(s1), cap(s1)) s2 := make([]int, 6, 8) // 使用用 make 创建,指定 len 和 cap 值。 fmt.Println(s2, len(s2), cap(s2)) s3 := make([]int, 6) // 省略 cap,相当于 cap = len。 fmt.Println(s3, len(s3), cap(s3))
-
可用指针直接访问底层数组,退化成普通数组操作。
s := []int{0, 1, 2, 3} p := &s[2] // *int, 获取底层数组元素指针。 *p += 100 fmt.Println(s) // [0 1 102 3]
-
基于已有 slice 创建新 slice 对象,新对象依旧指向原底层数组。
s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} s1 := s[2:5] // [2 3 4] s1[2] = 100 s2 := s1[2:6] // [100 5 6 7] s2[3] = 200 fmt.Println(s) // [0 1 2 3 100 5 6 200 8 9]
-
append/copy
-
-
map 哈希表
-
初始化
m := make(map[string]int, 1000) m := map[string]int{ "a": 1, } if v, ok := m["a"]; ok { // 判断 key 是否存在。 println(v) } println(m["c"]) // 对于不存在的 key,直接返回 \0,不会出错。 m["b"] = 2 // 新增或修改。 delete(m, "c") // 删除。如果 key 不存在,不会出错。 println(len(m)) // 获取键值对数量。cap 无无效。 for k, v := range m { // 迭代,可仅返回 key。随机顺序返回,每次都不相同。 println(k, v) }
-
从 map 中取回的是一个 value 临时复制品,对其成员的修改是没有任何意义的。正确做法是完整替换 value 或使用用指针。
type user struct{ name string } m := map[int]user{ 1: {"user1"}, // 当 map 因扩张而而重新哈希时,各键值项存储位置都会发生生改变。 因此,map // 被设计成 not addressable。 类似 m[1].name 这种期望透过原 value } // 指针修改成员的行行为自自然会被禁止止。 m[1].name = "Tom" // Error: cannot assign to m[1].name u := m[1] u.name = "Tom" m[1] = u // 替换 value。 m2 := map[int]*user{ 1: &user{"user1"}, } m2[1].name = "Jack" // 返回的是指针复制品。透过指针修改原对象是允许的。
-
-
struct
-
支持指向自身类型的指针成员。
-
顺序初始化必须包含全部字段,否则会出错。
type Node struct { _ int id int data *byte next *Node }
-
匿名字段
可以像普通字段那样访问匿名字段成员, 编译器从外向内逐级查找所有层次的匿名字段,直到发现目标或出错。
type User struct { name string } type Manager struct { User title string }
外层同名字段会遮蔽嵌入入字段成员,解决方法是使用用显式字段名。
不能同时嵌入入某一类型和其指针类型,因为它们名字相同。
-
-
面向对象
- 面向对象三大大特征里里, Go 仅支持封装(匿名字段的内存布局和行为类似继承)。没有class 关键字,没有继承、多态等等。
-
方法
-
方法总是绑定对象实例,并隐式将实例作为第一实参 (receiver)。
-
只能为当前包内命名类型定义方法。 参数 receiver 可任意命名。如方法中未曾使用,可省略参数名。 参数 receiver 类型可以是 T 或 *T。基类型 T 不能是接口或指针。 不支持方法重载,receiver 只是参数签名的组成部分。 可用用实例 value 或 pointer 调用用全部方法,编译器自动转换。
-
没有构造和析构方法,通常用简单工厂模式返回对象实例。
type Queue struct { elements []interface{} } func NewQueue() *Queue { // 创建对象实例。 return &Queue{make([]interface{}, 10)} } func (*Queue) Push(e interface{}) error { // 省略 receiver 参数名。 panic("not implemented") } // func (Queue) Push(e int) error { // panic("not implemented") // Error: method redeclared: Queue.Push // } func (self *Queue) length() int { // receiver 参数名可以是 self、this 或其他。 return len(self.elements) }
-
不支持多级指针查找方法成员。
-
通过匿名字段,可获得和继承类似的复用能力。依据编译器查找次序,只需在外层定义同名方法,就可以实现 “override”。
type User struct { id int name string } type Manager struct { User title string } func (self *User) ToString() string { return fmt.Sprintf("User: %p, %v", self, self) } func (self *Manager) ToString() string { return fmt.Sprintf("Manager: %p, %v", self, self) } func main() { m := Manager{User{1, "Tom"}, "Administrator"} fmt.Println(m.ToString()) fmt.Println(m.User.ToString()) }
-
-
Interface
-
接口命名习惯以 er 结尾,结构体。 接口只有方法签名,没有实现。 接口没有数据字段。 可在接口中嵌入其他接口。
-
空接口 interface{} 没有任何方法签名,也就意味着任何类型都实现了空接口。其作用类似面向对象语言言中的根对象 object。
type Stringer interface { String() string } type Printer interface { Stringer // 接口口嵌入入。 Print() } type User struct { id int name string } func (self *User) String() string { return fmt.Sprintf("user %d, %s", self.id, self.name) } func (self *User) Print() { fmt.Println(self.String()) } func main() { var t Printer = &User{1, "Tom"} // *User 方方法集包含 String、Print。 t.Print() }
-
接口对象由接口表 (interface table) 指针和数据指针组成。 只有 tab 和 data 都为 nil 时,接口才等于 nil。
var a interface{} = nil // tab = nil, data = nil var b interface{} = (*int)(nil) // tab 包含 *int 类型信息, data = nil type iface struct { itab, data uintptr } ia := *(*iface)(unsafe.Pointer(&a)) ib := *(*iface)(unsafe.Pointer(&b)) fmt.Println(a == nil, ia) fmt.Println(b == nil, ib, reflect.ValueOf(b).IsNil()) // // true {0 0} // false {505728 0} true
这个特性,官方有个关于error的有趣的描述:https://golang.org/doc/faq#nil_error,简单来说就是不要自己定义error,免得判断 nil 时候出问题。这里还有个类似的例子:
-
数据指针持有的是目标对象的只读复制品,复制完整对象或指针。
type User struct { id int name string } func main() { u := User{1, "Tom"} var i interface{} = u u.id = 2 u.name = "Jack" fmt.Printf("%v\n", u) fmt.Printf("%v\n", i.(User)) } // {2 Jack} // {1 Tom}
-
接口转型返回临时对象,只有使用指针才能修改其状态。
type User struct { id int name string } func main() { u := User{1, "Tom"} var vi, pi interface{} = u, &u // vi.(User).name = "Jack" // Error: cannot assign to vi.(User).name pi.(*User).name = "Jack" fmt.Printf("%v\n", vi.(User)) fmt.Printf("%v\n", pi.(*User)) }
-
接口类型判断
type User struct { id int name string } func (self *User) String() string { return fmt.Sprintf("%d, %s", self.id, self.name) } func main() { var o interface{} = &User{1, "Tom"} if i, ok := o.(fmt.Stringer); ok { // ok-idiom fmt.Println(i) } u := o.(*User) // u := o.(User) // panic: interface is *main.User, not main.User fmt.Println(u) }
批量判断:
func main() { var o interface{} = &User{1, "Tom"} switch v := o.(type) { case nil: // o == nil fmt.Println("nil") case fmt.Stringer: // interface fmt.Println(v) case func() string: // func fmt.Println(v()) case *User: // *struct fmt.Printf("%d, %s\n", v.id, v.name) default: fmt.Println("unknown") } }
-
让编译器检查,以确保某个类型实现接口。
var _ fmt.Stringer = (*Data)(nil)
-
-
并发goroutine
-
入口函数 main 就以 goroutine 运行。另有与之配套的 channel 类型,实现 “以通讯来共享内存” 的 CSP 模式。
go func() { println("Hello, World!") }()
-
调度器不能保证多个 goroutine 执行行次序,且进程退出时不会等待它们结束。
-
默认情况下,进程启动后仅允许一个系统线程服务于 goroutine。可使用环境变量或标准库函数 runtime.GOMAXPROCS 修改,让调度器用多个线程实现多核并行,而不仅仅是并发。
-
调用 runtime.Goexit 将立即终止当前 goroutine 执行,调度器确保所有已注册 defer延迟调用被执行。
-
-
channel
-
简单使用
var chanName chan ElementType // 定义 c := make(chan int, 1024) // 初始化,带缓冲区 ch <- value // 将一个数据写入(发送)至channel的语法 value := <-ch // 从channel中读取数据 // 不带缓冲区时,向channel写入数据和读取数据会导致程序阻塞,直到有其他goroutine从这个channel中读取数据/写入数据为止。 close(ch) //关闭channel
-
默认为同步模式,需要发送和接收配对。否则会被阻塞。
-
异步方式通过判断缓冲区来决定是否阻塞。如果缓冲区已满,发送被阻塞;缓冲区为空,接收被阻塞。
-
异步 channel 可减少排队阻塞,具备更高的效率。
func main() { data := make(chan int) // 数据交换队列 exit := make(chan bool) // 退出通知 go func() { for d := range data { // 从队列迭代接收数据,直到 close 。 fmt.Println(d) } fmt.Println("recv over.") exit <- true // 发出退出通知。 }() data <- 1 // 发送数据。 data <- 2 data <- 3 close(data) // 关闭队列。 fmt.Println("send over.") <-exit // 等待退出通知。 }
-
channel 应该考虑使用指针规避大大对象拷贝,将多个元素打包,减小缓冲区大小等。
-
除用 range 外,还可用 ok-idiom 模式判断 channel 是否关闭。
for { if d, ok := <-data; ok { fmt.Println(d) } else { break } }
-
单向 chan, 不能将单向 channel 转换为普通 channel。
c := make(chan int, 3) var send chan<- int = c // send-only var recv <-chan int = c // receive-only send <- 1 发送数据 // <-send // Error: receive from send-only type chan<- int <-recv // recv <- 2 // Error: send to receive-only type <-chan int
-
在循环中使用 select default case 需要小心,避免形成洪水。
-
-
工具
-
go build
-
go install
-
go clean
-
go get
-
go tool objdump
-
跨平台编译
-
数据竞争 (data race)
-
go test
-
Benchmark
-
PProf
-
四、hello world
main.go
package main
import "fmt"
func main() {
fmt.Printf("hello, world\n")
}
初始化go module
go mod init kelu.org/apptest
go mod tidy
go build
这样会生成一个 apptest 的可执行文件。