Golang踩坑总结

从 15 年起,陆陆续续得写了几年 Go ,但大多都是小工具和一些简单的服务。最近一段时间用 Golang 多了一些,有了一些经验类的东西可以记录和分享。包括下面几点

  • Golang 项目结构和 Makefile 的组织
  • Golang 的依赖管理
  • Golang 的调试(使用 dlv 工具)
  • Golang 服务的 profile
  • CGo 的编写和使用
  • Golang 标准库的使用中的常见问题

项目结构和 Makefile

一个 Golang 工程的典型结构如下, 将 GOPATH 设置为 pwd,编译时可以自动去 src/<ProjectName> 寻找代码,其他的内部库函数等也可以放到 src 下,并且依赖可以都放到 src/vendor 目录中。

.
├── LICENSE
├── Makefile
├── README.md
├── bin
├── log
├── conf
│   ├── conf.toml
├── src
│   ├── LibName
│   ├── ProjectName
│   │   └── main.go
│   └── vendor

Makefile 中指定 GOPATH 为当前路径,然后将生成的 bin 文件输出到 bin 目录中。

all: build

GOPATH=${CURDIR}
BINARY=<PackageName>
PackageName=<PackageName>
CONF=./conf/
LOG=./log

.PHONY: build build_linux64 clean package
	
build:
	GOPATH=${GoPATH} go build -o bin/${BINARY} ${PackageName}

build_linux64:
	GOPATH=${GoPATH} GoARCH=amd64 GoOS=linux go build -o bin/${BINARY} ${PackageName}

debug_build: 
	GOARCH=amd64 GoOS=linux go build -a -race -gcflags "all=-N -l" -ldflags '-extldflags "-static"' ${PackageName}

clean:
	rm -f *.tar.gz ${BINARY}
	rm -f *.tar.gz ${LOG}/*

package: build_linux64
	rm -f *.tar.gz ${LOG}/*
	zip ${PackageName}.zip bin/${BINARY} conf/example.conf

依赖管理

旧版本的 go 中可以使用 gvt 工具来管理依赖。 使用 go get https://github.com/FiloSottile/gvt 安装 gvt。(需要 export PATH=$PATH:$GOPATH/bin)

然后在根目录下

export GOPATH=`pwd` 

再到 src 目录下执行 gvt fetch -precaire <package name>

下载的依赖和其版本会记录到 manifest 文件中,源文件则会存储在 src/vendor 中。

Go 1.11、1.12及后续版本会开始支持 Go Modules

在已存在的项目中,只需要先

cd src/<PackageName>

然后执行

Go111MODULE=on go mod init

即可初始化 go module,生成 go.mod 文件。

再执行 GO111MODULE=on go build ./... 即可自动下载对应的依赖,下载的依赖会存储在 $GOPATH/pkg/mod 中, 其版本会写入 go.mod 中。

module MithrilSSHTunnel

require (
        github.com/BurntSushi/toml v0.3.1
        golang.org/x/crypto v0.0.0-20190617133340-57b3e21c3d56
)

Go 1.13 project-layout

https://github.com/golang-standards/project-layout

CGo

Golang 支持和 C 代码交互,可以在 Go 源码中使用特殊的语法编写 C 代码然后一起打包,也可以使 Go 代码和 C 动态链接库通过 ABI 进行交互。参考 https://golang.org/cmd/cgo/。

Go 源码中使用 C

Go 源码中使用 C 代码的示例如下

test.go

package main

// #include <stdio.h>
// #include <stdlib.h>
// void hello(char * s)
// {
//     printf("%s", s);
// }
import "C"
import "unsafe"

func main() {
        cs := C.CString("Hello World")
        C.hello(cs)
        C.free(unsafe.Pointer(cs))
}

go run test.go 即可输出 Hello World

使用 go tool cgo -debug-gcc test.go 可以得到中间代码和对象,在当前目录的 _obj 目录下。

$ ls _obj
_cgo_.o         _cgo_export.h   _cgo_gotypes.go test.cgo1.go
_cgo_export.c   _cgo_flags      _cgo_main.c     test.cgo2.c

使用 readelf -s 可以看出 go build 编译后的文件中包含 hello 函数的代码和符号。

readelf -s test |grep hello
    71: 0000000000453630    22 FUNC    GLOBAL DEFAULT   15 hello
  1381: 00000000004533a0   121 FUNC    LOCAL  DEFAULT   15 main._Cfunc_hello
  1459: 0000000000453630    22 FUNC    GLOBAL DEFAULT   15 hello

Go 链接动态链接库

在上面的 test.go 中,使用了 stdlib.h, stdio.h 等头文件,并使用了其中的 printf 和 free 函数,C 的标准库函数在 Linux 上位于 glibc 中,go 编译出的文件会通过 ABI 来调用。

例如 free 函数,使用 readelf 查看符号可以看到 test 文件中引用了 GLIBC 中的 free 符号。

1380: 0000000000453320   121 FUNC    LOCAL  DEFAULT   15 main._Cfunc_free
1426: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND free@@GLIBC_2.2.5

除了和系统自带的 so 交互外,用户也可以将自己或第三方的 C 库和 Go 一起编译打包部署,即在 Makefile 中先编译 C 代码为 so 与 Go 的 bin 文件一起发布。

使用 dlv 调试 Go 代码

在 Linux 上,Golang 虽然也是编译成 ELF 文件运行,但因为 Go 有其独特的运行时和调试信息,直接使用 GDB 调试有很多功能受限,具体可以参考 https://golang.org/doc/gdb 。

由于上面的原因, Golang 团队开发了一个专门用于 Go 语言调试的工具,delve

安装

dlv 的安装很简单,使用 go get -u github.com/go-delve/delve/cmd/dlv 并保证 $GoPATH/bin$PATH 中即可。

命令行

  • 使用 dlv attach <pid> attach 到一个已在运行的 go 进程中。
  • 使用 dlv exec <path> 拉起要 debug 的程序。

Vars

vars 支持正则查找变量

vars –v 查看详情

(dlv) vars  <ProjectName>/<PackagetName>.<VarName>

Vars 不能查看结构体内容

print

(dlv) print  "<ProjectName>r/<PackagetName>".<VarName>.<StructField> 

*sync.Map { 

        mu: sync.Mutex {state: 0, sema: 0}, 

        read: sync/atomic.Value { 

                v: interface {}(sync.readOnly) *(*interface {})(0xc42007f4a8),}, 

        dirty: map[interface {}]*sync.entry [ 

                *(*interface {})(0xc470b30008): *(*sync.entry)(0xc4200960e0), 

                *(*interface {})(0xc470b30018): *(*sync.entry)(0xc420096110), 

                *(*interface {})(0xc470b30028): *(*sync.entry)(0xc420096120), 

                *(*interface {})(0xc470b30038): *(*sync.entry)(0xc420096130), 

                *(*interface {})(0xc470b30048): *(*sync.entry)(0xc420096148), 

                *(*interface {})(0xc470b30058): *(*sync.entry)(0xc420096168), 

                *(*interface {})(0xc470b300d8): *(*sync.entry)(0xc4200960d8), 

                *(*interface {})(0xc470b300e8): *(*sync.entry)(0xc4200960e8), 

                *(*interface {})(0xc470b300f8): *(*sync.entry)(0xc420096108), 

                *(*interface {})(0xc470b30108): *(*sync.entry)(0xc420096138), 

                *(*interface {})(0xc470b30118): *(*sync.entry)(0xc420096158), 

        ], 

        misses: 0,} 

Print 可以支持包路径,并且查看包中结构体等变量的内容。

如 sync map 可以使用 .dirty 查看内容


(dlv) print  "<ProjectName>r/<PackagetName>".<VarName>.<StructField>.dirty 

map[interface {}]*sync.entry [ 
        "baidu.com": *{p: unsafe.Pointer(0xc470b34410)}
] 

Set breakpoints

b "<ProjectName>r/<PackagetName>".<VarName>

set break in struct function

<ProjectName>/<PackagetName>".(*<StructName>).<FunctionName>

Golang 服务的 profile

Go 提供了 profile 工具,可以很方便的对已经编写好的 Go 服务程序进行 profile。参考 https://blog.golang.org/profiling-go-programs

只需要在代码中添加几个 http 接口,即可在服务运行中拿到实时的性能数据,并且对服务本身的性能影响可以忽略不计。

参考 https://artem.krylysov.com/blog/2017/03/13/profiling-and-optimizing-go-web-applications/

添加如下的 http 接口:

DEBUG PROFILE
http.HandleFunc("/api/v1/debug/pprof/", pprof.Index)
http.HandleFunc("/api/v1/debug/pprof/cmdline", pprof.Cmdline)
http.HandleFunc("/api/v1/debug/pprof/profile", pprof.Profile)
http.HandleFunc("/api/v1/debug/pprof/symbol", pprof.Symbol)
http.HandleFunc("/api/v1/debug/pprof/trace", pprof.Trace)
http.HandleFunc("/api/test/",enforceJSONHandler(sadata.))

使用 apache bench 对业务接口进行压力测试,同时在本地使用 go tool pprof http://<remoteip>:<remote_port>/api/v1/debug/pprof/profile 即可链接上远程的 profile 接口,拉取 profile 信息。

ab -k  -p body.txt -T application/json -c 1000 -n 20000 http://<host/api/v1/

pprof 命令行中使用 top 命令可以查看 cpu 使用占比,使用 web 命令则会生成 svg 调用图,可以很方便的分析性能瓶颈。

Golang 标准库的使用中的常见问题

Golang HTTP Client 的使用和常见问题

在使用 Go 标准的 “net/http” 库中的 http client 时,可以自定义 http client 的参数,默认的 DefaultClient 没有超时时间。

HTTPClient = &http.Client{
  Transport: &http.Transport{
    DialContext: (&net.Dialer{
      Timeout:   10 * time.Second,
      KeepAlive: 30 * time.Second,
    }).DialContext,
    TLSHandshakeTimeout: 10 * time.Second,
    MaxIdleConns:        1000,
    MaxIdleConnsPerHost: 1000,
    IdleConnTimeout:     time.Duration(90) * time.Second,
  },
  Timeout: 2 * time.Second,
}

使用 http client 发送 post 请求并解析返回的 json 示例代码如下:

func DoPost(url string) (map[string]interface{}, error) {
  req := make(map[string]interface{})
  body := make(map[string]interface{})
	req_body, _ := json.Marshal(req)
	reader := bytes.NewReader(req_body)
	resp, err := HTTPClient.Post(url, "Content-Type: Application/json", reader)
	if err != nil {
		fmt.Printf("http req error %s", err.Error())
		return body, err
	}
	if resp.StatusCode != 200 {
		mess := fmt.Sprintf("http status %d", resp.StatusCode)
		return body, errors.New(mess)
	}
	defer resp.Body.Close()
	respBody, err := ioutil.ReadAll(resp.Body)

	if err != nil {
		mess := fmt.Sprintf("Read body error %s", err.Error())
		logdef.LogError(mess)
		return body, errors.New(mess)
  }
  
	err = json.Unmarshal(respBody, &body)
	if err != nil {
		mess := fmt.Sprintf("Json parse error %s", err.Error())
		return body, errors.New(mess)
  }
  return body, nil
}

使用 http client 下载数据时,需要注意服务器有没有开启 gzip 压缩功能,如果开启了 gzip 压缩,则返回的 http respsone 中可能是分块传输的,不会有 Content-Length 字段,这时读取 Get 返回的 Response 中的 ContentLength 会返回 -1。

参考 wiki,返回的 HTTP Response 头如下:

HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked

下载文件时,服务器可能会返回文件名,位于 Response Header 的 Content-Disposition 字段中,例如 Content-Disposition: attachment; filename="filename.jpg"。具体的定义可以参考 MDN

处理这种情况的示例代码如下:

func HttpReqFile(url string, downloadPath string) (string, error) {
	resp, err := http.Get(url)
	if err != nil {
		return "", err
	}
	if resp.StatusCode != 200 {
		return "", err
	}
	defer resp.Body.Close()

	length := resp.ContentLength
	logdef.LogInfof("rsp length:%v", length)
	encryContent := new(ServerReplayAll)

	filename := "DefaultFileName"
	filepath := fmt.Sprintf("%s%s", downloadPath, filename)

	ContentDisposition := resp.Header.Get("Content-Disposition")
	if len(ContentDisposition) > 0 {
		_, params, err := mime.ParseMediaType(ContentDisposition)
		if err != nil {
			fmt.Println("Parse Content-Disposition Failed")
		} else {
			if filename, ok := params["filename"]; ok {
				filepath = fmt.Sprintf("%s%s", downloadPath, filename)
			} else {
				fmt.Println("Parse Content-Disposition Failed No FileName")
			}
		}
	}

	if length > 0 {
		respBody := make([]byte, length, length)
		n, err := io.ReadFull(resp.Body, respBody)
		if err != nil {
			return "", err
		}
		if ioutil.WriteFile(filepath, respBody, 0644) == nil {
			fmt.Println("wirte success ", filepath)
		} else {
			fmt.Println("wirte failure ", filepath)
			filepath = ""
		}
	} else if length == -1 {
		fmt.Println("resp.ContentLength is Unkown(-1)")
		out, err := os.Create(filepath)
		if err != nil {
			fmt.Printf("create file failure %v: err:%v", filepath, err)
			filepath = ""
		}
		defer out.Close()
		wlen, err := io.Copy(out, resp.Body)
		if err != nil {
			fmt.Printf("wirte failure ", filepath)
			filepath = ""
		}
		fmt.Printf("Write FIle Length:%v", wlen)
	} else if length == 0 {
		fmt.Println("resp.ContentLength is 0!")
		return "", errors.New("ContentLength is 0")
  }
  return filepath, nil
}

还有一个问题是,HTTPClient 设置了 Transport 配置后,不会读取默认的环境遍历中的代理配置。

type Transport struct {
	// Proxy specifies a function to return a proxy for a given
	// Request. If the function returns a non-nil error, the
	// request is aborted with the provided error.
	//
	// The proxy type is determined by the URL scheme. "http",
	// "https", and "socks5" are supported. If the scheme is empty,
	// "http" is assumed.
	//
	// If Proxy is nil or returns a nil *URL, no proxy is used.
	Proxy func(*Request) (*url.URL, error)
}

可以在创建 HTTPClient 时在 Transport 中指定 Proxy 为默认的 http.ProxyFromEnvironment

HTTPClient = &http.Client{ Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, }, Timeout: time.Duration(5 * time.Second), }

Golang HTTP Client multipart/form-data 上传文件

使用 multipart.NewWriter 来创建表单数据, NewWriter 会随机创建一个 Boundary,浏览器中会自动创建这个字段。如果要指定 Boundary 则需要在创建 NewWriter 后调用 SetBoundary。

CreateFormFile 函数封装了 CreatePart,会默认创建 "Content-Type", "application/octet-stream" 的 form data。

发送 POST 请求前,需要使用 request.Header.Set("Content-Type", w.FormDataContentType()) 设置 Header 的 Content-Type, 这里如果不使用 SetBoundary 则会是随机 boundary

func NewWriter(w io.Writer) *Writer {
	return &Writer{
		w:        w,
		boundary: randomBoundary(),
	}
}

NewWriter 需要在发送请求前手动调用 Close,因为最后一个 boundary 的末尾会多两个横线 -- 来标识结束,会在调用 Close 时添加。

func uploadFile(fpath string) error {
	var body bytes.Buffer
	w := multipart.NewWriter(&body)
	w.SetBoundary("---011000010111000001101001")
	err := w.WriteField("a", "field_a")
	if err != nil {
		mess := fmt.Sprintf("WriteField error %s", err.Error())
		glog.Error(mess)
		return err
	}

	file, err := os.Open(fpath)

	if err != nil {
		glog.Error(err)
		return err
	}

	defer file.Close()

	h := make(textproto.MIMEHeader)
	h.Set("Content-Disposition",
		fmt.Sprintf(`form-data; name="%s"; filename="%s"`,
			escapeQuotes("image"), escapeQuotes(filepath.Base(file.Name()))))
	h.Set("Content-Type", "image/png")
	part, err := w.CreatePart(h)

	//part, err := w.CreateFormFile("image", filepath.Base(file.Name()))
	if err != nil {
		glog.Error(err)
		return err
	}

	io.Copy(part, file)

	w.Close()

	var uploadRaspBody uploadResp
	request, err := http.NewRequest("POST", uploadAPI, &body)
	if err != nil {
		mess := fmt.Sprintf("Create Request error %s", err.Error())
		glog.Error(mess)
		return err
	}
	//request.Header.Set("Content-Type", "multipart/form-data; boundary=---011000010111000001101001")
	request.Header.Set("Content-Type", w.FormDataContentType())

	resp, err := HTTPClient.Do(request)
	if err != nil {
		mess := fmt.Sprintf("http req error %s", err.Error())
		glog.Error(mess)
		return err
	}
	defer resp.Body.Close()
	respBody, err := ioutil.ReadAll(resp.Body)

	if err != nil {
		mess := fmt.Sprintf("Read body error %s", err.Error())
		glog.Error(mess)
		return errors.New(mess)
	}

	if resp.StatusCode != 200 {
		mess := fmt.Sprintf("http status %d", resp.StatusCode)
		glog.Error("Resp Body : %s", string(respBody))
		glog.Error(mess)
		return errors.New(mess)
	}

	err = json.Unmarshal(respBody, &uploadRaspBody)
	if err != nil {
		mess := fmt.Sprintf("Json parse error %s", err.Error())
		return errors.New(mess)
	}
	fmt.Println(uploadRaspBody)
	return nil
}

最后发送的实际请求如下

POST /uploadurl/ HTTP/1.1
Host: uploadserver
User-Agent: Go-http-client/1.1
Content-Length: 867216
Content-Type: multipart/form-data; boundary=---011000010111000001101001
Accept-Encoding: gzip

-----011000010111000001101001
Content-Disposition: form-data; name="a"

field_a
-----011000010111000001101001
Content-Disposition: form-data; name="b"

field_b
-----011000010111000001101001
Content-Disposition: form-data; name="image"; filename="test.png"
Content-Type: image/png

PNG
<Content>
-----011000010111000001101001--

参考 RFC1867(https://tools.ietf.org/html/rfc1867)