使用 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 工具集,可以轻松的查看服务状态、查看日志、设置重启策略,开机启动等。具体可以看我之前梳理的文档: systemdjournalctl

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 中寻找变量 VersionBuildTime ,然后动态赋值。可以这样写 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 = ""

程序开始打印变量即可。

First created: 2020-03-17 16:32:37
Last updated: 2022-12-11 Sun 12:49
Power by Emacs 29.0.91 (Org mode 9.6.6)