Golang踩坑总结
Golang踩坑总结
从 15 年起,陆陆续续得写了几年 Go ,但大多都是小工具和一些简单的服务。最近一段时间用 Golang 多了一些,有了一些经验类的东西可以记录和分享。包括下面几点
- Golang 项目结构和 Makefile 的组织
- Golang 的依赖管理
- Golang 的调试(使用 dlv 工具)
- Golang 服务的 profile
- CGo 的编写和使用
- Golang 标准库的使用中的常见问题
- Golang 中格式化日期字符串
- Golang HTTP Client 的使用和常见问题
项目结构和 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 不能查看结构体内容
(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)