Go语言开发(八)、Go语言程序测试与性能调优
一、Go语言自动化测试框架简介
1、自动化测试框架简介
go语言标准包的testing提供了单元测试(功能性测试)和性能测试(压力测试)常用方法的框架,可以非常方便地利用其进行自动化测试。
go语言测试代码只需要放到以 _test.go 结尾的文件中即可。golang的测试分为单元测试和性能测试,单元测试的测试用例必须以Test开头,其后的函数名不能以小写字母开头;性能测试必须以Benchmark开头,其后的函数名不能以小写字母开头。为了测试方法和被测试方法的可读性,一般Test或Benchmark后为被测试方法的函数名。测试代码通常与测试对象文件在同一目录下。
2、单元测试
Go语言单元测试的测试用例必须以Test开头,其后的函数名不能以小写字母开头。
add.go文件:
package add
func add(a,b int)int{
return a + b
}
单元测试用例:
package add
import "testing"
func TestAdd(t *testing.T){
sum := add(1,2)
if sum == 3 {
t.Logf("add(1,2) == %d",sum)
}
}
上述代码测试数据与测试逻辑混合在一起,根据Go语言的特点和工程实践,产生了一种表格驱动测试方法。表格驱动测试将测试数据集中保存在切片中,测试数据与测试逻辑实现了分离。
表格驱动测试:
package add
import "testing"
func TestAdd(t *testing.T) {
//定义测试数据
tests := []struct{ a, b, c int }{
{3, 4, 7},
{5, 12, 17},
{8, 15, 23},
{12, 35, 47},
{30000, 40000, 70000},
}
//测试逻辑
for _,tt := range tests{
if actual := add(tt.a,tt.b);actual != tt.c{
t.Errorf("Add(%d,%d) got %d;expected %d", tt.a,tt.b,actual,tt.c)
}
}
}
表格驱动测试的优点:
A、分离测试数据和测试逻辑
B、明确出错信息
C、可以部分失败
D、Go语言更容易实现表格驱动测试
执行测试:go test
结果如下:
[user@localhost test]$ go test -v
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok _/home/user/GoLang/test 0.001s
3、性能测试
性能测试即压力测试(BMT: Benchmark Testing)。
性能测试用例:
func BenchmarkAdd(t *testing.B){
//重置时间点
t.ResetTimer()
for i := 0; i < t.N; i++{
add(1,2)
}
}
完整测试代码如下:
package add
import "testing"
func TestAdd(t *testing.T) {
//定义测试数据
tests := []struct{ a, b, c int }{
{3, 4, 7},
{5, 12, 17},
{8, 15, 23},
{12, 35, 47},
{30000, 40000, 70000},
}
//测试逻辑
for _,tt := range tests{
if actual := add(tt.a,tt.b);actual != tt.c{
t.Errorf("Add(%d,%d) got %d;expected %d", tt.a,tt.b,actual,tt.c)
}
}
}
func BenchmarkAdd(t *testing.B){
//重置时间点
t.ResetTimer()
for i := 0; i < t.N; i++{
add(1,2)
}
}
执行测试:go test -bench=.
结果如下:
[user@localhost test]$ go test -bench=.
goos: linux
goarch: amd64
BenchmarkAdd-4 2000000000 0.38 ns/op
PASS
ok _/home/user/GoLang/test 0.803s
4、代码覆盖率测试
测试覆盖率是用于通过执行某包的测试用例来确认到的描述其的代码在测试用例中被执行的程度的术语。
在go语言的测试覆盖率统计时,go test通过参数covermode的设定可以对覆盖率统计模式作如下三种设定:
A、set:缺省模式, 只记录语句是否被执行过
B、count:记录语句被执行的次数
C、atomic:记录语句被执行的次数,并保证在并发执行时的正确性
执行覆盖率测试:go test -cover
结果如下:
[user@localhost test]$ go test -cover
PASS
coverage: 100.0% of statements
ok _/home/user/GoLang/test 0.001s
执行命令,生成代码覆盖率测试信息:go test -coverprofile=covprofile
查看covprofile文件信息:
[user@localhost test]$ cat covprofile
mode: set
_/home/user/GoLang/test/add.go:3.21,5.2 1 1
[user@localhost test]$
将生成代码覆盖率测试信息转换为HTML格式:go tool cover -html=covprofile -o coverage.html
使用浏览器查看coverage.html文件。
二、go tool pprof性能分析工具
1、go tool pprof简介
Golang内置cpu、mem、block三种profiler采样工具,允许程序在运行时使用profiler进行数据采样,生成采样文件。通过go tool pprof工具可以交互式分析采样文件,得到高可读性的输出信息。
任何以go tool开头的Go命令内部指向的特殊工具都被保存在目录$GOROOT/pkg/tool/$GOOS_$GOARCH/目录,即Go工具目录。pprof工具并不是用Go语言编写的,而是由Perl语言编写。Perl语言可以直接读取源码并运行。因此,pprof工具的源码文件被直接保存在Go工具目录下。
pprof工具是用Perl语言编写的,执行go tool pprof命令的前提条件是需要在当前环境下安装Perl语言
go tool pprof命令会分析指定的概要文件并使得能够以交互式的方式访问其中的信息。但只有概要文件还不够,还需要概要文件中信息的来源——命令源码文件的可执行文件。而可以运行的Go语言程序只能是编译命令源码文件后生成的可执行文件。
2、profile采样文件简介
在Go语言中,可以通过标准库的代码包runtime和runtime/pprof中的程序来生成三种包含实时性数据的概要文件,分别是CPU概要文件、内存概要文件和程序阻塞概要文件。
A、CPU概要文件
CPU的主频,即CPU内核工作的时钟频率(CPU Clock Speed)。CPU的主频的基本单位是赫兹(Hz)。时钟频率的倒数即为时钟周期。在一个时钟周期内,CPU执行一条运算指令。在1000 Hz的CPU主频下,每1毫秒可以执行一条CPU运算指令;在1 MHz的CPU主频下,每1微妙可以执行一条CPU运算指令;在1 GHz的CPU主频下,每1纳秒可以执行一条CPU运算指令。
在默认情况下,Go语言的运行时系统会以100 Hz的的频率对CPU使用情况进行取样,即每秒取样100次(每10毫秒会取样一次)。100 Hz既足够产生有用的数据,又不至于让系统产生停顿,并且100容易做换算。对CPU使用情况的取样就是对当前的Goroutine的堆栈上的程序计数器的取样。由此,可以从样本记录中分析出哪些代码是计算时间最长或者说最耗CPU资源的部分。可以通过以下代码启动对CPU使用情况的记录。
func startCPUProfile() {
if *cpuProfile != "" {
f, err := os.Create(*cpuProfile)
if err != nil {
fmt.Fprintf(os.Stderr, "Can not create cpu profile output file: %s",
err)
return
}
if err := pprof.StartCPUProfile(f); err != nil {
fmt.Fprintf(os.Stderr, "Can not start cpu profile: %s", err)
f.Close()
return
}
}
}
在函数startCPUProfile中,首先创建了一个用于存放CPU使用情况记录的文件,即CPU概要文件,其绝对路径由*cpuProfile的值表示。然后,把profile文件的实例作为参数传入到函数pprof.StartCPUProfile中。如果pprof.StartCPUProfile函数没有返回错误,说明记录操作已经开始。只有CPU概要文件的绝对路径有效时,pprof.StartCPUProfile函数才会开启记录操作。
如果想要在某一时刻停止CPU使用情况记录操作,需要调用以下函数:
func stopCPUProfile() {
if *cpuProfile != "" {
pprof.StopCPUProfile() // 把记录的概要信息写到已指定的文件
}
}
在以上函数中,并没有代码用于CPU概要文件写入操作。在启动CPU使用情况记录操作后,运行时系统就会以每秒100次的频率将采样数据写入到CPU概要文件中。pprof.StopCPUProfile函数通过把CPU使用情况取样的频率设置为0来停止取样操作。只有当所有CPU使用情况记录都被写入到CPU概要文件后,pprof.StopCPUProfile函数才会退出,保证CPU概要文件的完整性。
B、内存概要文件
内存概要文件用于保存在用户程序执行期间的内存使用情况,即程序运行过程中堆内存的分配情况。Go语言运行时系统会对用户程序运行期间的所有的堆内存分配进行记录。不论在取样的哪一时刻、堆内存已用字节数是否有增长,只要有字节被分配且数量足够,分析器就会对其进行取样。开启内存使用情况记录的可以使用以下函数:
func startMemProfile() {
if *memProfile != "" && *memProfileRate > 0 {
runtime.MemProfileRate = *memProfileRate
}
}
开启内存使用情况记录的方式非常简单。在函数startMemProfile中,只有在memProfile和memProfileRate的值有效时才会进行后续操作。memProfile的含义是内存概要文件的绝对路径。memProfileRate的含义是分析器的取样间隔,单位是字节。当将memProfileRate值赋给int类型的变量runtime.MemProfileRate时,意味着分析器将会在每分配指定的字节数量后对内存使用情况进行取样。实际上,即使不给runtime.MemProfileRate变量赋值,内存使用情况的取样操作也会照样进行。此取样操作会从用户程序开始时启动,且一直持续进行到用户程序结束。runtime.MemProfileRate变量的默认值是512 1024,即512K个字节。只有当显式的将0赋给runtime.MemProfileRate变量后,才会取消取样操作。
在默认情况下,内存使用情况的取样数据只会被保存在运行时内存中,而保存到文件的操作只能由开发者自己来完成。取消采样操作代码如下:
func stopMemProfile() {
if *memProfile != "" {
f, err := os.Create(*memProfile)
if err != nil {
fmt.Fprintf(os.Stderr, "Can not create mem profile output file: %s", err)
return
}
if err = pprof.WriteHeapProfile(f); err != nil {
fmt.Fprintf(os.Stderr, "Can not write %s: %s", *memProfile, err)
}
f.Close()
}
}
stopMemProfile函数的功能是停止对内存使用情况的取样操作。stopMemProfile只做了将取样数据保存到内存概要文件的操作。在stopMemProfile函数中,调用函数pprof.WriteHeapProfile,并把代表内存概要文件的文件实例作为参数。如果pprof.WriteHeapProfile函数没有返回错误,就说明数据已被写入到了内存概要文件中。
对内存使用情况进行取样的程序会假定取样间隔在用户程序的运行期间内都是一成不变的,并且等于runtime.MemProfileRate变量的当前值。因此,应该在Go程序中只改变内存取样间隔一次,且应尽早改变。比如,在命令源码文件的main函数的开始处就改变内存采样间隔。
C、程序阻塞概要文件
程序阻塞概要文件用于保存用户程序中的Goroutine阻塞事件的记录。开启程序阻塞采样的代码如下:
func startBlockProfile() {
if *blockProfile != "" && *blockProfileRate > 0 {
runtime.SetBlockProfileRate(*blockProfileRate)
}
}
在函数startBlockProfile中,当blockProfile和blockProfileRate的值有效时,会设置对Goroutine阻塞事件的取样间隔。blockProfile的含义为程序阻塞概要文件的绝对路径。blockProfileRate的含义是分析器的取样间隔,单位是次。函数runtime.SetBlockProfileRate的唯一参数是int类型的,含义是分析器会在每发生几次Goroutine阻塞事件时对阻塞事件进行取样。如果不显式的使用runtime.SetBlockProfileRate函数设置取样间隔,那么取样间隔就为1。即在默认情况下,每发生一次Goroutine阻塞事件,分析器就会取样一次。运行时系统对Goroutine阻塞事件的取样操作也会贯穿于用户程序的整个运行期。但是,如果通过runtime.SetBlockProfileRate函数将取样间隔设置为0或者负数,那么取样操作就会被取消。
在程序结束前可以将被保存在运行时内存中的Goroutine阻塞事件记录存放到指定的文件中。代码如下:
func stopBlockProfile() {
if *blockProfile != "" && *blockProfileRate >= 0 {
f, err := os.Create(*blockProfile)
if err != nil {
fmt.Fprintf(os.Stderr, "Can not create block profile output file: %s", err)
return
}
if err = pprof.Lookup("block").WriteTo(f, 0); err != nil {
fmt.Fprintf(os.Stderr, "Can not write %s: %s", *blockProfile, err)
}
f.Close()
}
}
在创建程序阻塞概要文件后,stopBlockProfile函数会先通过函数pprof.Lookup将保存在运行时内存中的内存使用情况记录取出,并在记录的实例上调用WriteTo方法将记录写入到文件中。
3、profiling使用场景
A、基准测试
使用go test -bench . -cpuprofile prof.cpu
生成基准测试的采样文件,再通过命令go tool pprof [binary] prof.cpu
对采样文件进行分析。
B、Web服务测试
如果应用是一个web服务,可以在http服务启动的代码文件添加import _ net/http/pprof,Web服务会自动开启profile功能,辅助开发者直接分析采样结果。可以在浏览器中使用http://localhost:port/debug/pprof/
直接看到当前web服务的状态,包括CPU占用情况和内存使用情况等。
C、应用程序
如果go程序是一个应用程序,不能使用net/http/pprof包,需要使用runtime/pprof包。使用pprof.StartCPUProfile、pprof.StopCPUProfile或是内存采样、阻塞采样接口等对运行时信息进行采样。最终使用go tool pprof工具对采样文件进行分析。
D、服务进程
如果go程序不是web服务器,而是一个服务进程,那么也可以选择使用net/http/pprof包,同样引入包net/http/pprof,然后再开启另外一个goroutine来开启端口监听。
go func() {
log.Println(http.ListenAndServe("localhost:6666", nil))
}()
4、pprof使用
编写一个简单的应用程序,使用pprof.StartCPUProfile和pprof.StopCPUProfile对CPU信息进行采样。
package main
import (
"flag"
"log"
"os"
"runtime/pprof"
"fmt"
)
// 斐波纳契数列
func Fibonacci() func() int {
back1, back2 := 1, 1
return func() int {
//重新赋值
back1, back2 = back2, (back1 + back2)
return back1
}
}
func count(){
a := 0;
for i := 0; i < 10000000000; i++ {
a = a + i
}
}
var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file")
func main() {
flag.Parse()
if *cpuprofile != "" {
f, err := os.Create(*cpuprofile)
if err != nil {
log.Fatal(err)
}
pprof.StartCPUProfile(f)
defer f.Close()
}
fibonacci := Fibonacci()
for i := 0; i < 100; i++ {
fmt.Println(fibonacci())
}
count()
defer pprof.StopCPUProfile()
}
进行运行时信息采样时,可以指定不同的采样参数:
--cpuprofile:指定CPU概要文件的保存路径
--blockprofile:指定程序阻塞概要文件的保存路径。
--blockprofilerate:定义其值为n,指定每发生n次Goroutine阻塞事件时,进行一次取样操作。
--memprofile:指定内存概要文件的保存路径。
--memprofilerate:定义其值为n,指定每分配n个字节的堆内存时,进行一次取样操作。
运行go程序,对CPU信息进行采样:go run fibonacci.go --cpuprofile=profile.cpu
分析CPU采样文件profile.cpu:go tool pprof profile.cpu
如果Go程序非常简单,比如只有fibonacci()函数调用(注释count()函数),使用pprof.StartCPUProfile是打印不出任何信息的。
默认情况下top命令会列出前10项内容。可以top命令后面紧跟一个数字,限制列出的项数。
三、go-torch性能分析工具
1、go-torch简介
go-torch是Uber公司开源的一款针对Golang程序的火焰图生成工具,能收集stack traces,整理成火焰图,并直观地显示程序给开发人员。go-torch是基于使用BrendanGregg创建的火焰图工具生成直观的图像,方便地分析Go的各个方法所占用CPU的时间。
2、FlameGraph安装
git clone https://github.com/brendangregg/FlameGraph.git
sudo cp FlameGraph/flamegraph.pl /usr/local/bin
在终端输入flamegraph.pl -h测试FlameGraph是否安装成功
3、go-torch安装
go get -v github.com/uber/go-torch
go-torch默认安装在GOPATH指定的第一个目录中,位于bin目录下。
4、go-wrk压力测试
安装go-wrk压力测试工具:go get -v github.com/adjust/go-wrk
执行35s 1W次高并发场景模拟:go-wrk -d 35 -n 10000 http://localhost:port/demo
5、go-torch使用
在Web服务压力测试过程中,使用go-torch生成采样文件。go-torch -u http://localhost:port -t 30
go-torch完成采样时输出如下信息:Writing svg to torch.svg
torch.svg是go-torch自动生成的profile文件,使用浏览器打开如下:
火焰图的y轴表示cpu调用方法的先后,x轴表示在每个采样调用时间内,方法所占的时间百分比,越宽代表占据cpu时间越多。
根据火焰图可以清楚的查看哪个方法调用耗时长,然后不断的修正代码,重新采样,不断优化。
四、Go语言程序性能优化
1、内存优化
A、将小对象合并成结构体一次分配,减少内存分配次数
Go runtime底层采用内存池机制,每个span大小为4k,同时维护一个cache。cache有一个0到n的list数组,list数组的每个单元挂载的是一个链表,链表的每个节点就是一块可用的内存块,同一链表中的所有节点内存块都是大小相等的;但是不同链表的内存大小是不等的,即list数组的一个单元存储的是一类固定大小的内存块,不同单元里存储的内存块大小是不等的。cache缓存的是不同类大小的内存对象,申请的内存大小最接近于哪类缓存内存块时,就分配哪类内存块。当cache不够时再向spanalloc中分配。
B、缓存区内容一次分配足够大小空间,并适当复用
在协议编解码时,需要频繁地操作[]byte,可以使用bytes.Buffer或其它byte缓存区对象。
bytes.Buffer等通过预先分配足够大的内存,避免当增长时动态申请内存,减少内存分配次数。对于byte缓存区对象需要考虑适当地复用。
C、slice和map采make创建时,预估大小指定容量
slice和map与数组不一样,不存在固定空间大小,可以根据增加元素来动态扩容。
slice初始会指定一个数组,当对slice进行append等操作时,当容量不够时,会自动扩容:
如果新的大小是当前大小2倍以上,则容量增涨为新的大小;
否则循环以下操作:如果当前容量小于1024,按2倍增加;否则每次按当前容量1/4增涨,直到增涨的容量超过或等新大小。
map的扩容比较复杂,每次扩容会增加到上次容量的2倍。map的结构体中有一个buckets和oldbuckets,用于实现增量扩容:
正常情况下,直接使用buckets,oldbuckets为空;
如果正在扩容,则oldbuckets不为空,buckets是oldbuckets的2倍,
因此,建议初始化时预估大小指定容量
D、长调用栈避免申请较多的临时对象
Goroutine的调用栈默认大小是4K(1.7修改为2K),采用连续栈机制,当栈空间不够时,Go runtime会自动扩容:
当栈空间不够时,按2倍增加,原有栈的变量会直接copy到新的栈空间,变量指针指向新的空间地址;
退栈会释放栈空间的占用,GC时发现栈空间占用不到1/4时,则栈空间减少一半。
比如栈的最终大小2M,则极端情况下,就会有10次的扩栈操作,会带来性能下降。
因此,建议控制调用栈和函数的复杂度,不要在一个goroutine做完所有逻辑;如的确需要长调用栈,而考虑goroutine池化,避免频繁创建goroutine带来栈空间的变化。
E、避免频繁创建临时对象
Go在GC时会引发stop the world,即整个情况暂停。Go1.8最坏情况下GC为100us。但暂停时间还是取决于临时对象的个数,临时对象数量越多,暂停时间可能越长,并消耗CPU。
因此,建议GC优化方式是尽可能地减少临时对象的个数:尽量使用局部变量;所多个局部变量合并一个大的结构体或数组,减少扫描对象的次数,一次回尽可能多的内存。
2、并发优化
A、高并发的任务处理使用goroutine池
Goroutine虽然轻量,但对于高并发的轻量任务处理,频繁来创建goroutine来执行,执行效率并不会太高,因为:过多的goroutine创建,会影响go runtime对goroutine调度,以及GC消耗;高并发时若出现调用异常阻塞积压,大量的goroutine短时间积压可能导致程序崩溃。
B、避免高并发调用同步系统接口
goroutine的实现,是通过同步来模拟异步操作。
网络IO、锁、channel、Time.sleep、基于底层系统异步调用的Syscall操作并不会阻塞go runtime的线程调度。
本地IO调用、基于底层系统同步调用的Syscall、CGo方式调用C语言动态库中的调用IO或其它阻塞会创建新的调度线程。
网络IO可以基于epoll的异步机制(或kqueue等异步机制),但对于一些系统函数并没有提供异步机制。例如常见的posix api中,对文件的操作就是同步操作。虽有开源的fileepoll来模拟异步文件操作。但Go的Syscall还是依赖底层的操作系统的API。系统API没有异步,Go也做不了异步化处理。
因此,建议:把涉及到同步调用的goroutine,隔离到可控的goroutine中,而不是直接高并的goroutine调用。
C、高并发时避免共享对象互斥
传统多线程编程时,当并发冲突在4~8线程时,性能可能会出现拐点。Go推荐不通过共享内存来通信,Go创建goroutine非常容易,当大量goroutine共享同一互斥对象时,也会在某一数量的goroutine出在拐点。
因此,建议:goroutine尽量独立,无冲突地执行;若goroutine间存在冲突,则可以采分区来控制goroutine的并发个数,减少同一互斥对象冲突并发数。
3、其它优化
A、避免使用CGO或者减少CGO调用次数
GO可以调用C库函数,但Go带有垃圾收集器且Go的栈动态增涨,无法与C无缝地对接。Go的环境转入C代码执行前,必须为C创建一个新的调用栈,把栈变量赋值给C调用栈,调用结束现拷贝回来。调用开销较大,需要维护Go与C的调用上下文,两者调用栈的映射。相比直接的GO调用栈,单纯的调用栈可能有2个甚至3个数量级以上。
因此,建议:尽量避免使用CGO,无法避免时,要减少跨CGO的调用次数。
B、减少[]byte与string之间转换,尽量采用[]byte来字符串处理
GO里面的string类型是一个不可变类型,GO中[]byte与string底层是两个不同的结构,转换存在实实在在的值对象拷贝,所以尽量减少不必要的转化。
因此,建议:存在字符串拼接等处理,尽量采用[]byte。
C、字符串的拼接优先考虑bytes.Buffer
string类型是一个不可变类型,但拼接会创建新的string。GO中字符串拼接常见有如下几种方式:
string + 操作 :导致多次对象的分配与值拷贝
fmt.Sprintf :会动态解析参数,效率好不哪去
strings.Join :内部是[]byte的append
bytes.Buffer :可以预先分配大小,减少对象分配与拷贝
因此,建议:对于高性能要求,优先考虑bytes.Buffer,预先分配大小。fmt.Sprintf可以简化不同类型转换与拼接。
五、Go程序文档生成
1、go doc工具简介
go doc 工具会从Go程序和包文件中提取顶级声明的首行注释以及每个对象的相关注释,并生成相关文档。
go doc也可以作为一个提供在线文档浏览的web服务器。
2、终端查看文档
go doc package:获取包的文档注释,例如:go doc fmt 会显示使用 godoc 生成的 fmt 包的文档注释。
go doc package/subpackage: 获取子包的文档注释,例如:go doc container/list。
go doc package function :获取某个函数在某个包中的文档注释,例如:go doc fmt Printf 会显示有关 fmt.Printf() 的使用说明。
3、在线浏览文档
godoc支持启动一个Web在线API文档服务,在命令行执行:godoc -http=:6666
启动Web服务后,使用浏览器打开http://127.0.0.1:6666
,可以看到本地文档浏览服务器提供的页面。
经测试,Google Chrome浏览器不能访问godoc开启的Web服务。
4、生成文档
Go文档工具支持开发人员自己写的代码,只要开发者按照一定的规则,就可以自动生成文档。
add.go文件如下:
/*
add function will be add a and b.
return a+b
*/
package add
// add
func add(a,b int)int{
return a + b
}
生成文文档如下:
[user@localhost add]$ go doc
package add // import "add"
add function will be add a and b. return a+b
[user@localhost add]$
go doc工具会将包文件中顶级声明的首行注释提取出来。如果函数为private(函数名称首字母小写),go doc工具会隐藏函数。
/*
add function will be add a and b.
return a+b
*/
package add
// add
func Add(a,b int)int{
return a + b
}
生成文档如下:
[user@localhost add]$ go doc
package add // import "add"
add function will be add a and b. return a+b
func Add(a, b int) int
[user@localhost add]$
5、添加文档示例
Go语言的文档中添加示例代码的步骤如下:
A、示例代码必须单独存放在一个文件(文件名字为example_test.go)中或是测试代码文件中。
B、在示例代码文件里,定义一个名字为Example的函数,参数为空
C、示例的输出采用注释的方式,以// Output:开头,另起一行,每行输出占一行。
package add
import (
"fmt"
)
func Example(){
sum := add(1,2)
fmt.Println(sum)
// Output:
// 3
}
生成文档结果如下:
有疑问加站长微信联系(非本文作者)