使用 Go 构建 Web 服务技术栈

Table of Contents

本文将梳理使用 Go 构建一个 Web server 的方方面面,随时更新.

1 开发

项目布局结构可参考 project-layout 中的说明,这里 是我的翻译。核心目录:

  • cmd/ 入口
    • main.go main
    • options/
      • options.go 服务配置参数解析,提供服务的全局配置,比如 Port,比如 DB 连接信息
  • internal/ 不可对外的包
    • apiserver/
      • server.go server 初始化、启、停等接口
      • router.go URL 路由注册
      • middleware.go 中间件,比如鉴权
      • ${module}handler 不同的 URL 对应的处理器,业务逻辑都放这里,比如 userhandler
    • xxx/ apiserver 中需要的模块,比如数据操作封装
  • pkg/ 可对外的包,可单独被其他项目引用

1.1 框架选择

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.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.1.3 结论

使用原生的库还是框架,我的思路是:

  • 如果服务侧重于 agent/proxy 对外暴露的 API 比较单一,比如用于收集机器 metric 的 exporter,建议使用原生
  • 如果服务侧重于业务,对外暴露的 API 很多,需要分组和鉴权相关,建议用框架

本文档的示例用的都是 gin,其实区别不大的。

1.2 最佳实践

1.2.1 服务名称

一个服务应该有一个唯一标识:应用ID 或者应用名等,它在组织内部应该是唯一的,这种做法对于基建是很友好的(大部分公司也是这么做的)。

1.2.2 流程流转

  1. main 统一流转
    1. options.InitServerConfig()
      • flag.parse()
      • yaml/json parse 配置文件解析
    2. NewAPIServer
    3. StartServer
    4. 接收服务终止信号 StopServer
  2. server server 流转
    1. NewAPIServer 资源初始化(比如:DB 连接)、路由初始化、中间件挂载、 http.Server 初始化
    2. StartServer 启动服务、监听请求 HttpServer.ListenAndServe()
    3. StopServer 资源销毁、关闭 Server

1.2.3 使用配置文件,而不是参数注入

配置文件选择 JSON、YAML,一般使用 YAML,全部参数注入在参数情况下,启动服务会变的复杂。

package options

type ServerConfig struct {
    Host string `yaml:"host"`
    Port string `yaml:"port"`
}

func flagParse() string {
    configFile := ""
    flag.StringVar(&configFile, "config-file", "/etc/xxx-server/config.yaml", "server configure file")
    flag.Parse()

    logrus.Tracef("config file = %s", configFile)
    return configFile
}

func parseConfigFile(configFile string) (*ServerConfig, error) {
    file, err := ioutil.ReadFile(configFile)
    if err != nil {
        return nil, err
    }

    cfg := &ServerConfig{}
    err = yaml.Unmarshal(file, cfg)
    if err != nil {
        return nil, err
    }

    logrus.Tracef("parse config success, cfg=%#v", cfg)
    return cfg, nil
}

var cfg *ServerConfig

func InitServerConfig() error {
    configFile := flagParse()
    cfg_, err := parseConfigFile(configFile)
    if err != nil {
        return err
    }

    cfg = cfg_
    return nil
}

func GetCfg() *ServerConfig {
    return cfg
}

这是一个单例,使用时,直接调用 options.GetCfg() 即可。

1.2.4 服务优雅关闭

优雅关闭一般指的是,关闭服务时把该处理的请求处理完毕,相关的资源正常清理结束之后再程序自动运行结束。而不是直接杀进程。

quit := make(chan os.Signal)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logrus.Infof("recv shutdown server signal...")

func (server *APIServer) StopServer() {
    defer func() {
        // 清理其它资源
    }()

    waiting := 5 * time.Second
    ctx, cancel := context.WithTimeout(context.Background(), waiting)
    defer cancel()

    if err := server.HttpServer.Shutdown(ctx); err != nil {
        logrus.Fatalf("server shutdown failure, error=%s", err)
    }

    select {
    case <-ctx.Done():
        logrus.Infof("waiting finish, %v", waiting)
    }

    logrus.Infof("server exiting")
}

上面的逻辑是等待 SIGINTSIGTERM 信号,然后开始关闭服务,给 waiting 的时间让服务 Shutdown ,然后对资源进行清理。

1.2.5 使用 API 分组

不管是 mux 还是 gin 都支持 API 分组,mux 隐式一些是 subPath ,而 gin 直接叫 Group

分组最直接的好处是业务模块多了之后,API 会变的很清晰,对于 gin,你还可以针对不同的分组,使用不同的中间件。

1.2.6 日志

根据云原生倡导,日志统一打印到标准输出。 日志收集和查看是基建需要做的事情,程序应与此解耦。

1.2.7 监控:使用 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()))

1.3 常用库选型

Go 包选择与对比 中有一些列举,说到的这里就不赘述了。

1.3.1 日志

使用 logrus,基本设置:

func init() {
    logrus.SetFormatter(&logrus.TextFormatter{
        ForceColors:     false,
        DisableColors:   true,
        FullTimestamp:   true,
        TimestampFormat: "2006-01-02 15:04:05.000",
        CallerPrettyfier: func(frame *runtime.Frame) (function string, file string) {
            ss := strings.Split(frame.Function, ".")
            function = ss[len(ss)-1]
            file = fmt.Sprintf("%s:%d", filepath.Base(frame.File), frame.Line)
            return function, file
        },
    })
    logrus.SetOutput(os.Stdout)

    debug := os.Getenv("DEBUG") // 没有明确关闭,则默认打开
    if debug == "" || debug == "true" || debug == "1" {
        logrus.SetReportCaller(true)
        logrus.SetLevel(logrus.TraceLevel)
    } else {
        logrus.SetReportCaller(false)
        logrus.SetLevel(logrus.InfoLevel)
    }
}

1.3.2 MySQL

使用 go-sql-driver/mysql,但是它是一个驱动,使用使用 SQL 的 CRUD,还是要看 Go 的标准库 database/sql。 看起来有些蛋疼,但我觉得这是 Go 标准化做的好的一个体现。不管是你用的什么 SQL,只要有驱动,就可以一视同仁。 这样的话,比如从 MySQL 到 MS SQL Server,只需要在初始化的做一下变更,其它部分代码基本上不需要动。

驱动列表可以见:https://github.com/golang/go/wiki/SQLDrivers

官方的使用范例:https://github.com/golang/go/wiki/SQLInterface

Python 也提供了 PEP 249 – Python Database API Specification v2.0,但它更像是一个协议上的约束,并不是实际限制库的实现。

使用标准库对 DB 操作时,需要手写 SQL 的 CRUD,一方面不方便,另外一方面也不安全。有一些包提供 SQL 构建器(用来生成 SQL 语句)。

比如:doug-martin/goqu,你可以这样:

ds := goqu.Insert("user").
    Cols("first_name", "last_name").
    Vals(
        goqu.Vals{"Greg", "Farley"},
        goqu.Vals{"Jimmy", "Stewart"},
        goqu.Vals{"Jeff", "Jeffers"},
    )
insertSQL, args, _ := ds.ToSQL()
fmt.Println(insertSQL, args)

会输出:

INSERT INTO "user" ("first_name", "last_name") VALUES ('Greg', 'Farley'), ('Jimmy', 'Stewart'), ('Jeff', 'Jeffers') []

它是一个 SQL 语句生成器,并不会实际的执行。

再比如:squirrel ,你可以这样:

sql, args, err := sq.
    Insert("users").Columns("name", "age").
    Values("moe", 13).Values("larry", sq.Expr("? + 5", 12)).
    ToSql()

它会生成:

sql == "INSERT INTO users (name,age) VALUES (?,?),(?,? + 5)"

但是它也允许直接执行 SQL 语句:

stooges := users.Where(sq.Eq{"username": []string{"moe", "larry", "curly", "shemp"}})
three_stooges := stooges.Limit(3)
rows, err := three_stooges.RunWith(db).Query()

等价于:

rows, err := db.Query("SELECT * FROM users WHERE username IN (?,?,?,?) LIMIT 3",
    "moe", "larry", "curly", "shemp")

goqu 和 squirrel 是社区比较出名的两个 SQL 构建器。我自己使用下来:

  • goqu 基本上没有什么学习成本,也很好 debug,但自己还是要封装 SQL 调用和异常处理;
  • squirrel 基本上封装了 SQL 调用这一层,自己全做了,用起来省事,但有一定的学习成本;

具体用哪个看个人。

<2020-05-13 Wed 17:44> squirrel 用习惯了真舒服,推荐!

1.3.3 MongoDB

建议使用官方提供的库:mongo-go-driver,开发已经一两年了,正式版本发布有六七个月了。 再测试版本的时候,我用过,比 mgo.v2 好用(mgo.v2 在社区很出名,但是已经不维护了, 这意味着随着 MongoDB 版本更新就会逐渐不适配了)。

2 打包

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}

3 部署

3.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

3.2 部署到容器环境下

在有容器运行时的情况下,建议使用容器部署。

3.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: 2020-06-29 Mon 15:50
Power by Emacs 26.3 (Org mode 9.3.7)