使用 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 流程流转

  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.2 使用配置文件,而不是参数注入

配置文件选择 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.3 服务优雅关闭

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

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.4 使用 API 分组

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

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

1.2.5 使用 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 部署到容器环境下

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

First created: 2020-03-17 16:32:37
Last updated: 2020-05-13 Wed 17:45
Power by Emacs 26.3 (Org mode 9.3.6)