使用 Go 构建 Web 服务技术栈
Table of Contents
本文将梳理使用 Go 构建一个 Web server 的方方面面,随时更新.
1 开发
项目布局结构可参考 project-layout 中的说明,这里 是我的翻译。核心目录:
cmd/
入口main.go
mainoptions/
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 流程流转
main
统一流转options.InitServerConfig()
flag.parse()
yaml/json parse
配置文件解析
NewAPIServer
StartServer
- 接收服务终止信号
StopServer
server
server 流转NewAPIServer
资源初始化(比如:DB 连接)、路由初始化、中间件挂载、http.Server
初始化StartServer
启动服务、监听请求HttpServer.ListenAndServe()
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") }
上面的逻辑是等待 SIGINT
和 SIGTERM
信号,然后开始关闭服务,给 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 调用这一层,自己全做了,用起来省事,但有一定的学习成本;
具体用哪个看个人。
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 工具集,可以轻松的查看服务状态、查看日志、设置重启策略,开机启动等。具体可以看我之前梳理的文档: systemd、journalctl。
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
中寻找变量 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 = ""
程序开始打印变量即可。