使用 Go 构建 Web 服务技术栈
Table of Contents
本文将梳理使用 Go 构建一个 Web server 的方方面面,随时更新.
1. 框架选择
1.1. 原生
Go 的 net/http
库比较丰富,借助 mux 即可自己完成一个 server 基本需求。
router := mux.NewRouter() router.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { resp.Write([]byte("ok")) }) httpServer := &http.Server{ Addr: ":8080", Handler: router, } go func() { if err := s.HTTPServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("server error, %s\n", err) } }()
1.2. gin
gin 是一个 Go 的 Web 框架,当然很多的其它框架。
router := gin.New() router.GET("/", func(c *gin.Context) { c.String(http.StatusOK, "ok") }) srv := &http.Server{ Addr: ":8080", Handler: router, } go func() { if err := server.HttpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("server failure : %s\n", err) } }()
有没发现,gin 的定位类似 mux,也是在解决路由问题。
当然,gin 除了提供 router 之外,还提供了 middleware 的功能,middleware 对于一个 Web Server 来讲是至关重要的,比如你可以:
func AuthRequired() gin.HandlerFunc { return func(c *gin.Context) { token := c.Request.Header.Get("X-Login-Token") if token != "xxx" { c.String(http.StatusUnauthorized, "login required") c.Abort() // must abort to next handler return } c.Set("username", "xxx") c.Next() } } apiv1 := r.Group("/api/v1") apiv1.Use(AuthRequired())
1.3. 结论
使用原生的库还是框架,我的思路是:
- 如果服务侧重于 agent/proxy 对外暴露的 API 比较单一,比如用于收集机器 metric 的 exporter,建议使用原生
如果服务侧重于业务,对外暴露的 API 很多,需要分组和鉴权相关,建议用框架
本文档的示例用的都是 gin,其实区别不大的。
2. 最佳实践
2.1. 日志
根据云原生倡导,日志统一打印到标准输出。 日志收集和查看是基建需要做的事情,程序应与此解耦。
2.2. 监控:使用 prometheus_client 暴露 API metrics
对接 promethues 之后可以很方便的监控 API 调用情况,调用耗时、状态码等等。因为 Prometheus 是 pull 的模式,所以服务很好解耦, 不会依赖 promethues server。
prometheus/client_golang 是 Go 的客户端。对于 gin,你可以写一个中间件来完成 API 耗时统计:
var ( APICalledLatency = prometheus.NewSummaryVec( prometheus.SummaryOpts{ Name: "metaserver_api_called_latency", Help: "Latency in microseconds", }, []string{"api", "method", "status_code"}, ) ) func init() { prometheus.MustRegister(APICalledLatency) } func SinceInMicroseconds(start time.Time) float64 { return float64(time.Since(start).Nanoseconds() / time.Microsecond.Nanoseconds()) } func metricsExport() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() defer func() { cost := SinceInMicroseconds(start) api := c.Request.URL.Path method := c.Request.Method status := strconv.Itoa(c.Writer.Status()) // set ignore itself in here if api == "/metrics" { return } APICalledLatency.WithLabelValues(api, method, status).Observe(cost) logrus.Tracef("api run time, api=%s, method=%s, status=%s, time=%f(ms)", api, method, status, cost/1000) }() c.Next() } }
因为 gin 的 Handler 不是标准库的 ServeHTTP(ResponseWriter, *Request)
,但是它提供了一个转换函数,我们可以这样:
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
3. 常用库选型
Go 包选择与对比 中有一些列举,说到的这里就不赘述了。
3.1. MongoDB
建议使用官方提供的库:mongo-go-driver,开发已经一两年了,正式版本发布有六七个月了。 再测试版本的时候,我用过,比 mgo.v2 好用(mgo.v2 在社区很出名,但是已经不维护了, 这意味着随着 MongoDB 版本更新就会逐渐不适配了)。
4. 打包
Go 支持跨平台编译,参考 构建环境 中的说明。
这里假定服务名称为: hello
,构建 Docker 镜像:
FROM Ubuntu:18.04 COPY hello /hello COPY config.yaml /etc/hello/config.yaml EXPOSE 8080 CMD ["/hello", "-config-file", "/etc/hello/config.yaml"]
一个简单的 Makefile:
OUTPUT=hello TAG=`git rev-parse --short HEAD` build: go build -o ${OUTPUT} cmd/main.go build-linux: GOOS=linux GOARCH=amd64 go build -o ${OUTPUT} cmd/main.go build-docker: GOOS=linux GOARCH=amd64 go build -o ${OUTPUT} cmd/main.go docker build -t <your docker registry addr>/${OUTPUT}:${TAG} . push-docker: docker push <your docker registry addr>/${OUTPUT}:${TAG}
5. 部署
5.1. 直接部署到主机上
构建对应平台的二进制包,Ubuntu 15.04 之后,以及 CentOS7 之后都使用 systemd 作为默认的系统和服务管理器了。 他提供了一些的工具集合,使用起来比 Supervisor 这种工具爽多了。
配置比较简单,使用 systemd 管理的进程,都会在 /usr/lib/systemd/system
目录下有一个 service 文件,用来编排你的应用。
比如:
[Unit] Description=Command Scheduler After=auditd.service systemd-user-sessions.service time-sync.target [Service] EnvironmentFile=/etc/sysconfig/crond ExecStart=/usr/sbin/crond -n $CRONDARGS ExecReload=/bin/kill -HUP $MAINPID KillMode=process [Install] WantedBy=multi-user.target
这是 crond 的配置文件,里面囊括了服务的描述、依赖、启动命令、配置文件等。
使用 systemd 工具集,可以轻松的查看服务状态、查看日志、设置重启策略,开机启动等。具体可以看我之前梳理的文档: systemd、journalctl。
5.2. 部署到容器环境下
在有容器运行时的情况下,建议使用容器部署。
5.3. 版本号
如果使用 Docker 部署时,版本号通常放到 docker tag 中。如果使用二进制文件直接部署的话,把版本号放在二进制名称上是一种办法, 但是很不优雅,这样每次发布都需要修改 systemd unit 的内容。最好的办法就是把版本信息打到二进制包中,通过执行某个参数时自动打印。
Go build -ldflags
支持注入变量,比如:
go build -ldflags -X main.Version=1.0.0 -X main.BuildTime=2020-06-29 15:29:45
将在模块 main
中寻找变量 Version
和 BuildTime
,然后动态赋值。可以这样写 makefile:
VERSION=`git rev-parse HEAD` BUILD_TIME=`date +'%Y-%m-%d %H:%M:%S'` LDFLAGS=-ldflags "-X 'main.Version=${VERSION}' -X 'main.BuildTime=${BUILD_TIME}'" build: go build ${LDFLAGS} -o xxx *.go
然后在 main
中声明变量:
Version string = "" BuildTime string = ""
程序开始打印变量即可。