本文是 Go + Docker API服务开发和部署 文章系列的开发篇,主要讲解如何使用Go来开发一个适用于生产环境的API服务。

  1. API服务功能介绍

API服务功能介绍

本文里我们要实现的API服务功能为几乎每个网站或移动应用都需要的用户系统功能,包括注册、登录、编辑资料和查询信息等接口。数据传输使用HTTP协议,响应结果为JSON格式。

Echo Web框架简介

Echo 是一个Go的Web Server & Framework。简单轻量,兼容Go自带的“net/http”包。支持route和middleware,提供了一批常用中间件,包括请求日志记录、JWT权限认证等。路由支持分组,以减少冗余的配置,可以按组添加中间件。中间件可以在全局、路由组和单个路由几个层级上配置,这样就可以控制中间件的作用范围,非常灵活。

代码目录结构

.
├── cmd // 子命令
│   ├── db
│   ├── db.go
│   ├── root.go
│   └── server.go
├── middlewares // 自定义中间件
│   └── reqresplogger.go
├── controllers // 控制器,处理请求参数,调用业务逻辑接口执行处理,返回结果
│   ├── account.go
│   ├── common.go
│   └── user.go
├── services // 业务逻辑,从控制器里剥离出来,一是减轻控制器,二是便于在各个控制器之间复用业务逻辑
│   ├── common.go
│   ├── error.go
│   ├── user.go
│   └── validator.go
├── models // 模型,负责跟数据库打交道
│   ├── common.go
│   ├── index.go
│   ├── player.go
│   └── user.go
├── views // 视图,对于API服务来说无用
├── static // 静态资源文件
├── test // 测试
│   ├── common.go
│   ├── controller
│   └── model
├── Godeps // Godep配置文件,自动生成
│   ├── Godeps.json
│   └── Readme
├── vendor // 依赖包,由Godeps自动生成,仅限本应用使用
│   ├── camlistore.org
│   ├── github.com
│   ├── golang.org
│   └── gopkg.in
├── README.md
├── main.go // 应用入口
├── config.json // 应用配置文件,按生产环境要求配置,其它环境通过命令行参数来调整,比如开启Debug模式、调整日志级别
├── supervisord.conf // Supervisor配置
├── common.sh // 脚本公用代码,比如日志打印
├── build.sh // 编译应用
├── restart.sh // 编译并重启服务
├── fswatch.sh // 监测代码修改并自动重启服务
├── goconvey.sh // 检测代码修改并自动运行测试
├── Dockerfile // Docker镜像配置文件,用来生成应用镜像
├── docker-build.sh // 构建开发环境应用镜像
├── docker-build-prod.sh // 构建生产环境应用镜像
├── docker-compose.yml // Docker compose配置文件
├── docker-compose.override.yml // 开发环境特定配置
├── docker-compose.test.yml // 集成测试环境特定配置
├── docker-compose.prod.yml // 生产环境特定配置
├── deploy.sh // 部署开发环境
├── deploy-test.sh // 部署集成测试环境
├── destroy-test.sh // 销毁集成测试环境
└── deploy-prod.sh // 部署生产环境

目录树中的文件接下来我们将一一讲解,其中部署相关的将在“部署篇” 使用Docker来搭建开发和测试环境,以及部署上线 中讲解。

MVCS模式介绍

与传统的MVC模式有一点区别,我们增加了Service层。把业务逻辑从控制器里剥离出来,也方便以后将功能模块独立出来以服务的方式部署。独立出来的服务只需实现Service里的公开接口就行。这种改进的模式我们称为 MVCS
MVCS

使用Godep来管理依赖

众所周知,Go使用一个全局的workspace,所有的包位于同一个命名空间中。每个包最顶层的目录名是组织机构域名,这样就避免了包名冲突。但对于复杂的生产环境应用来说,单一的workspace使得每个包同时只能存在一个版本,当多个应用依赖同一个包的不同版本时就会出现冲突。Godep 正是为了解决这个问题,它利用Go 1.5里面推出的vendor目录功能,自动扫描应用代码得到应用依赖包集合,并将这些依赖包从全局workspace中copy到vendor目录。编译应用时,Go如果发现vendor目录下有就会优先使用这个目录下的依赖包,这样各个应用的依赖包就互相隔离了,冲突自然也就不复存在。

安装Godep:

go get github.com/tools/godep

在应用根目录下执行下面的命令,创建vendor目录并保存应用的所有依赖包到该目录下。注意,如果依赖包有变化需要重新执行该命令。

godep save

已经存在的依赖包如果要升级,先执行 go get -u 命令,然后执行 godep update 来同步更新vendor目录。

使用Viper来管理配置

通过把一些容易变化和依赖环境的数据,比如数据库连接地址、日志打印级别、日志保存路径等,写到配置文件中,就能通过修改配置来改变应用行为,而无须经历修改代码,编译打包,部署上线这样比较重量的过程。Viper 是一种完整的应用配置管理工具。支持多种来源的配置,包括配置文件、命令行参数、环境变量、远程配置管理服务(etcd和consul),并且可以同时使用多种来源,每种来源有不同的优先级。配置文件支持JSON、TOML、YAML等常见格式。

本应用只使用配置文件和命令行参数两种。环境变量配置也会用到,但环境变量由Docker来管理,最终转化成命令行参数传递给应用。应用不直接使用环境变量,以简化配置管理。

配置文件 config.json

{
  "dir": {
    "data": "/data/zaiqiuchang/server/"
  },
  "server": {
    "listen_addr": ":1323",
    "pid_file": "server.pid",
    "debug": false
  },
  "jwt": {
    "secret": "...",
    "valid_days": 30
  },
  "log": {
    "level": "info",
    "echo": {
      "file": "echo.log"
    },
    "request": {
      "file": "request.log"
    },
    "reqresp": {
      "file": "reqresp.log"
    },
    "server": {
      "file": "server.log"
    }
  },
  "mongodb": {
    "zqc": {
      "addrs": "mongodb",
      "timeout": 1
    }
  }
}

其中各配置项的含义如下:

  • dir.data 运行时数据存放目录,比如日志、PID文件、用户上传文件等
  • server 配置服务监听端口、PID文件路径、是否开启debug模式等
  • jwt JWT 本应用中用来进行登录用户身份验证,其密钥和有效期写到配置里,以便管理
  • log 配置日志打印级别、不同类型日志的存放路径
  • mongodb MongoDB数据库连接地址

命令行参数绑定和配置文件加载:

// cmd/root.go

// ...

func init() {
  // 注册在Cobra初始化时要执行的应用初始化工作
  cobra.OnInitialize(initConfig, initLog)

  // 绑定命令行参数
  rootCmd.PersistentFlags().StringVarP(&rootFlags.cfgFile, "config", "c", "./config.json", "config file")
  rootCmd.PersistentFlags().StringP("dir.data", "d", "", "runtime data directory")
  rootCmd.PersistentFlags().Bool("server.debug", false, "enable/disable debug mode")
  rootCmd.PersistentFlags().String("log.level", "", "log level")
  rootCmd.PersistentFlags().String("mongodb.zqc.addrs", "", "addrs of zai qiu chang mongodb")

  viper.BindPFlags(rootCmd.PersistentFlags())
  viper.BindPFlags(rootCmd.Flags())

  // ...
}

// 初始化配置
func initConfig() {
  // 加载配置文件
  if e := os.Getenv("ZQC_CONFIG_FILE"); e != "" {
    rootFlags.cfgFile = e
  }
  viper.SetConfigFile(rootFlags.cfgFile)
  err := viper.ReadInConfig()
  if err != nil {
    panic(err)
  }
  fmt.Println("using config file", viper.ConfigFileUsed())

  // 实时检测配置文件变化,并在需要时重新加载
  viper.WatchConfig()
  viper.OnConfigChange(func(e fsnotify.Event) {
    fmt.Println("config changed")
  })
}

// 初始化日志,详见日志章节
func initLog() {
  // ...
}

因为我们使用了Cobra来管理子命令,所以调用的是Cobra的参数绑定接口,并没有直接调用Viper的接口。可以看到我们允许通过命令行来指定的配置项包括配置文件路径 config 、数据目录 dir.data 、是否开启Debug模式 server.debug 、日志打印级别 log.level 和MongoDB连接地址 "mongodb.zqc.addrs

代码里我们通过 viper.GetString("log.level") 这样的方式来读取配置项的值,命令行指定的优先,其次才从配置文件获取。

使用Cobra来管理子命令

搞开发的同学都会经常使用各种命令,比如git,使用Cobra 可以很快的实现类似git那样的命令行程序,并且支持子命令。

使用效果如下:

➜  server git:(master) zqc
Zai qiu chang app.

Usage:
  zqc [command]

Available Commands:
  db          Database admin
  server      Run server

Flags:
  -c, --config string              config file (default "./config.json")
  -d, --dir.data string            runtime data directory
  -h, --help                       help for zqc
      --log.level string           log level
      --mongodb.zqc.addrs string   addrs of zai qiu chang mongodb
      --server.debug               enable/disable debug mode

Use "zqc [command] --help" for more information about a command.

可以看到zqc这个命令,也就是我们的应用,支持db和server两个子命令,server子命令用来启动应用服务,db命令用来管理数据库,比如创建索引。

可以通过 -h 参数来查看每个子命令的帮助:

➜  server git:(master) zqc server -h
Run server.

Usage:
  zqc server [flags]

Flags:
  -l, --server.listen_addr string   server listen address

Global Flags:
  -c, --config string              config file (default "./config.json")
  -d, --dir.data string            runtime data directory
      --log.level string           log level
      --mongodb.zqc.addrs string   addrs of zai qiu chang mongodb
      --server.debug               enable/disable debug mode

cmd目录结构:

./cmd
├── db
│   ├── createindexes.go
│   ├── empty.go
│   └── listindexes.go
├── db.go
├── root.go
└── server.go

root定义Cobra入口命令,本身没什么用,只是用来加载子命令。同样db也是如此,其下有三个子命令创建索引 createindexes、清空数据库 empty和查看现有索引 listindexes。server子命令用来启动应用服务。

root入口命令:

// cmd/root.go

package cmd

// ...

// 创建root命令
var rootCmd = &cobra.Command{
  Use:   "zqc",
  Short: "Zai qiu chang app",
  Long:  `Zai qiu chang app.`,
}

// main文件的全部代码就是调用这个函数,执行root命令
func Execute() {
  if err := rootCmd.Execute(); err != nil {
    fmt.Println(err)
  }
}

func init() {
  // 注册在Cobra初始化时要执行的应用初始化工作
  cobra.OnInitialize(initConfig, initLog)

  // ...

  // 添加server和db两个子命令
  rootCmd.AddCommand(serverCmd)
  rootCmd.AddCommand(dbCmd)
}

// 初始化配置,详见配置章节
func initConfig() {
  // ...
}

// 初始化日志,详见日志章节
func initLog() {
  // ...
}

server子命令:

// cmd/server.go

// ...

// 创建server子命令
var serverCmd = &cobra.Command{
  Use:   "server",
  Short: "Run server",
  Long:  `Run server.`,
  // server子命令的实现,启动应用服务
  Run: func(cmd *cobra.Command, args []string) {
    e := echo.New()

    e.SetDebug(viper.GetBool("server.debug"))

    initEchoLog(e)

    e.SetHTTPErrorHandler(controllers.HttpErrorHandler)

    addMiddlewares(e)

    addRoutes(e)

    pid := os.Getpid()
    err := ioutil.WriteFile(filepath.Join(viper.GetString("dir.data"), viper.GetString("server.pid_file")), []byte(strconv.Itoa(pid)), 0644)
    if err != nil {
      e.Logger().Error(err)
    } else {
      e.Logger().Info("server pid ", pid)
    }

    addr := viper.GetString("server.listen_addr")
    e.Logger().Info("server listening on ", addr)
    e.Run(standard.WithConfig(engine.Config{
      Address:      addr,
      ReadTimeout:  5 * time.Second,
      WriteTimeout: 10 * time.Second,
    }))
  },
}

func init() {
  // 绑定server子命令特有的命令行参数,root命令里绑定的参数对server子命令也可用
  serverCmd.Flags().StringP("server.listen_addr", "l", "", "server listen address")

  viper.BindPFlags(serverCmd.PersistentFlags())

  viper.BindPFlags(serverCmd.Flags())

  // ...
}

// 初始化Echo框架日志打印
func initEchoLog(e *echo.Echo) {
  // ...
}

// 添加需要的中间件
func addMiddlewares(e *echo.Echo) {
  // ...
}

// 添加路由
func addRoutes(e *echo.Echo) {
  // ...
}

使用Logrus来打印日志

Go内置的log包短小精悍,已经能够满足基本的日志打印需求。Logrus 兼容内置的log包功能,已有代码只需要修改import,即可无缝切换到Logrus。除了支持内置的log包功能,Logrus还提供了其它高级功能,包括日志级别、上下文记录、JSON格式日志、上报日志到众多第三方日志平台等。

初始化日志:

// cmd/root.go

package cmd

// ...

func init() {
  // 注册在Cobra初始化时要执行的应用初始化工作
  cobra.OnInitialize(initConfig, initLog)

  // ...
}

// 初始化配置,详见配置章节
func initConfig() {
  // ...
}

// 初始化日志
func initLog() {
  log.SetFormatter(&log.JSONFormatter{})

  level, err := log.ParseLevel(viper.GetString("log.level"))
  if err != nil {
    panic(err)
  }
  log.SetLevel(level)

  w, err := os.OpenFile(filepath.Join(viper.GetString("dir.data"), viper.GetString("log.server.file")), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0640)
  if err != nil {
    panic(err)
  }
  log.SetOutput(w)
}

打印请求和响应的日志示例:

log.WithFields(map[string]interface{}{
  "req": map[string]interface{}{
    "method":     req.Method(),
    "uri":        req.URI(),
    "header":     req.Header(),
    "formParams": req.FormParams(),
  },
  "resp": map[string]interface{}{
    "status": resp.Status(),
    "header": resp.Header(),
    "body":   c.Get("respBody"),
  },
}).Debug("request and response")

打印到文件中的内容:

{"level":"debug","msg":"request and response","req":{"formParams":{},"header":{"Header":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, sdch"],"Accept-Language":["zh-CN,zh;q=0.8,en;q=0.6"],"Authorization":["Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0NzA0OTEzMjUsImlkIjoiNTc3ZTVkYWY3NmM2NzUxNWQ4ZThlNjAzIn0.YMY6rLiFadAtaug8ArBaDUEGwqhzPetc87Zd_Y9eZlw"],"Cache-Control":["no-cache"],"Connection":["keep-alive"],"User-Agent":["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36"]}},"method":"GET","uri":"/account/info"},"resp":{"body":{"code":0,"context":null,"data":{"user":{"id":"577e5daf76c67515d8e8e603","username":"jaggerwang","nickname":"jag","gender":"m","mobile":"18683420507","create_time":"2016-07-07T21:48:31.493+08:00"}},"message":""},"header":{"Header":{"Content-Type":["application/json; charset=utf-8"]}},"status":200},"time":"2016-07-07T21:49:32+08:00"}

Model层实现

不同的model代码单独到不同的文件中,其中包含model类型的定义和model所在表的定义。

// models/user.go

package models

import (
  "time"

  "gopkg.in/mgo.v2"
  "gopkg.in/mgo.v2/bson"
)

const (
  UserGenderMale   = "m"
  UserGenderFemale = "f"
)

// model结构
type User struct {
  ID         bson.ObjectId `bson:"_id,omitempty"`
  Username   string
  Password   string
  Salt       string
  Nickname   string
  Gender     string
  Mobile     string    `bson:",omitempty"`
  CreateTime time.Time `bson:"create_time"`
  UpdateTime time.Time `bson:"update_time,omitempty"`
}

// model表
type UserColl struct {
  *mgo.Collection
}

// 新建表对象,用于访问表
func NewUserColl() (uc *UserColl, err error) {
  // 创建表访问对象通过NewMongodbColl这个函数,详见models/common.go文件
  coll, err := NewMongodbColl("zqc", "zqc", "user")
  if err != nil {
    return nil, err
  }
  return &UserColl{
    Collection: coll,
  }, nil
}
// models/common.go

package models

import (
  "math"
  "strings"
  "time"

  "github.com/spf13/viper"
  "gopkg.in/mgo.v2"
)

// 创建MongoDB会话
func NewMongodbSession(clusterName string) (session *mgo.Session, err error) {
  config := viper.GetStringMap("mongodb." + clusterName)
  info := mgo.DialInfo{
    Addrs: strings.Split(config["addrs"].(string), ","),
  }
  timeout, ok := config["timeout"].(int64)
  if ok {
    info.Timeout = time.Duration(timeout * int64(math.Pow10(9)))
  }
  session, err = mgo.DialWithInfo(&info)
  if err != nil {
    return nil, err
  }
  session.SetMode(mgo.Monotonic, false)
  session.SetSafe(&mgo.Safe{
    WMode: "majority",
  })
  return session, nil
}

// 创建MongoDB数据库访问对象
func NewMongodbDB(clusterName string, dbName string) (db *mgo.Database, err error) {
  session, err := NewMongodbSession(clusterName)
  if err != nil {
    return nil, err
  }
  return session.DB(dbName), nil
}

// 创建MongoDB表访问对象
func NewMongodbColl(clusterName string, dbName string, collName string) (coll *mgo.Collection, err error) {
  db, err := NewMongodbDB(clusterName, dbName)
  if err != nil {
    return nil, err
  }
  return db.C(collName), nil
}

// ...

Service层实现

Service层处理业务逻辑,按功能划分文件。
每个service中定义了业务对象结构。业务对象跟model对象类似,但又有区别。业务对象表示的是model对象对外输出的结构,业务对象跟model对象不需要一一对应。这样做的好处是将model对象跟对外输出结构解耦,方便日后修改model。另外业务对象还可以对model对象进行裁剪,比如隐藏密码字段、转换文件路径为外部可访问的URL等。
除了业务对象结构定义,service文件里大多数代码就是业务逻辑实现了。每个业务逻辑接口的第一个返回参数为错误对象,里面包含具体的错误码,该错误码会响应给客户端。

// services/user.go

package services

import (
  "time"

  "gopkg.in/mgo.v2/bson"

  "zaiqiuchang.com/server/models"
)

// 业务对象结构,通过json tag指定对外输出形式
type User struct {
  ID         string     `json:"id"`
  Username   string     `json:"username"`
  Password   string     `json:"-"`
  Salt       string     `json:"-"`
  Nickname   string     `json:"nickname"`
  Gender     string     `json:"gender"`
  Mobile     string     `json:"mobile,omitempty"`
  CreateTime *time.Time `json:"create_time"`
  UpdateTime *time.Time `json:"update_time,omitempty"`
}

// 将model对象转换成业务对象
func NewUserFromModel(mu *models.User) (u *User) {
  return &User{
    ID:         mu.ID.Hex(),
    Username:   mu.Username,
    Password:   mu.Password,
    Salt:       mu.Salt,
    Nickname:   mu.Nickname,
    Gender:     mu.Gender,
    Mobile:     mu.Mobile,
    CreateTime: timePointerZeroNil(mu.CreateTime),
    UpdateTime: timePointerZeroNil(mu.UpdateTime),
  }
}

// 创建用户
func CreateUser(u *User) (err error, cu *User) {
  uc, err := models.NewUserColl()
  if err != nil {
    return NewError(ErrCodeSystem, err.Error()), nil
  }

  salt := randString(8, []rune{})
  password := md5StringSalt(u.Password, salt)
  err = uc.Insert(models.User{
    Username:   u.Username,
    Password:   password,
    Salt:       salt,
    Nickname:   u.Nickname,
    Gender:     u.Gender,
    Mobile:     u.Mobile,
    CreateTime: time.Now(),
  })
  if err != nil {
    return NewError(ErrCodeSystem, err.Error()), nil
  }

  return GetUserByUsername(u.Username)
}

// 编辑用户资料
func UpdateUser(id string, u *User) (err error, uu *User) {
  // ...
}

// 查询用户信息
func GetUser(id string) (err error, u *User) {
  // ...
}

// 更具用户名查询用户信息
func GetUserByUsername(username string) (err error, u *User) {
  // ...
}

// 验证用户名密码
func VerifyUserPassword(username string, password string) (err error, u *User) {
  // ...
}

Controller层实现

业务逻辑剥离到service里后,controller就比较轻量了,只需要负责处理请求参数,调用相应的业务逻辑接口,最后响应结果给客户端。参数验证这里我们使用的是 Govalidator 这个包,内置了很多验证规则,比如数字、邮箱地址、IP、MAC地址等,也可以自己自定义验证规则。

Controller示例:

// controllers/account.go

// ...

// 注册用户控制器接收参数结构
type RegisterAccountParams struct {
  username string `valid:"matches(^\w{3,20}$)"`
  password string `valid:"stringlength(4|20)"`
  nickname string `valid:"stringlength(3|20)"`
  gender   string `valid:"matches(^(?m|f))$)"`
  mobile   string `valid:"matches(^\d{11}$),optional"`
}

// 注册用户控制器
func RegisterAccount(c echo.Context) (err error) {
  // 获取参数并验证合法性
  params := RegisterAccountParams{
    username: c.FormValue("username"),
    password: c.FormValue("password"),
    nickname: c.FormValue("nickname"),
    gender:   c.FormValue("gender"),
    mobile:   c.FormValue("mobile"),
  }
  if _, err := valid.ValidateStruct(params); err != nil {
    return services.NewError(services.ErrCodeInvalidParams, err.Error())
  }

  // 调用service方法
  err, u := services.CreateUser(&services.User{
    Username: params.username,
    Password: params.password,
    Nickname: params.nickname,
    Gender:   params.gender,
    Mobile:   params.mobile,
  })
  if err != nil {
    fmt.Println("create user", err)
    return err
  }

  // 响应结果
  return responseJSON(c, response{
    data: map[string]interface{}{
      "user": u,
    },
  })
}

// ...

响应结果示例:

{
  "code": 0,
  "context": null,
  "data": {
    "user": {
      "id": "577e5daf76c67515d8e8e603",
      "username": "jaggerwang",
      "nickname": "jag",
      "gender": "m",
      "mobile": "18683420507",
      "create_time": "2016-07-07T21:48:31.493+08:00"
    }
  },
  "message": ""
}

响应结果我们使用统一的JSON格式,最顶层包含code、message、context和data三个字段,code即为service接口返回的错误编号,message为错误描述,context为出错时的上下文信息,data为可选的业务数据。只有在Debug模式下message才为具体的出错信息,非Debug模式只返回通用的错误描述。context也只有在Debug模式下才返回。错误号及其通用描述在统一的文件中进行管理。

响应结果组装以及错误响应处理:

// controllers/common.go

package controllers

import (
  "net/http"

  "github.com/labstack/echo"

  "zaiqiuchang.com/server/services"
)

// 响应结果结构
type response struct {
  status  int
  code    int
  message string
  context interface{}
  data    map[string]interface{}
}

// 生成响应结果
func responseJSON(c echo.Context, resp response) (err error) {
  // 如果不显示指定HTTP响应状态码,则默认为200
  if resp.status == 0 {
    resp.status = http.StatusOK
  }
  // 如果不显示指定错误描述,则使用默认的描述
  if resp.message == "" {
    resp.message = services.ErrMessages[resp.code]
  }

  // 创建标准的响应JSON结果
  respBody := map[string]interface{}{
    "code":    resp.code,
    "message": resp.message,
    "context": resp.context,
    "data":    resp.data,
  }
  c.Set("respBody", respBody)

  // 响应JSON结果
  return c.JSON(resp.status, respBody)
}

// 错误处理,保持跟正常请求一样的响应结果结构
func HttpErrorHandler(err error, c echo.Context) {
  status := http.StatusInternalServerError
  code := http.StatusInternalServerError
  msg := http.StatusText(code)
  var ctx interface{}
  // Echo框架产生的标准HTTP错误
  if he, ok := err.(*echo.HTTPError); ok {
    status = he.Code
    code = he.Code
    msg = he.Message
  // Service错误,包括HTTP响应状态码、错误码、错误描述、出错时的上下文
  } else if se, ok := err.(*services.Error); ok {
    status = http.StatusOK
    code = se.Code
    msg = services.ErrMessages[code]
    // Debug模式下输出辅助调试用的context上下文信息
    if c.Echo().Debug() {
      ctx = se.Context
    }
  }
  // Debug模式下输出具体错误描述
  if c.Echo().Debug() {
    msg = err.Error()
  }

  // 响应错误结果
  if !c.Response().Committed() {
    responseJSON(c, response{
      status:  status,
      code:    code,
      message: msg,
      context: ctx,
    })
  }

  // 记录错误日志
  c.Echo().Logger().Error(err)
}

响应错误码和描述定义:

// services/error.go

package services

const (
  // Common
  ErrCodeOk = iota
  ErrCodeSystem
  ErrCodeNotFound
  ErrCodeNoPermission
  ErrCodeInvalidParams

  // Account related
  ErrCodeWrongPassword = 1000 + iota
)

var ErrMessages = map[int]string{
  ErrCodeOk:            "成功",
  ErrCodeSystem:        "系统错误",
  ErrCodeNoPermission:  "没有权限",
  ErrCodeInvalidParams: "参数错误",

  ErrCodeWrongPassword: "密码错误",
}

type Error struct {
  Code    int
  Message string
  Context interface{}
}

func NewError(code int, message string, ctx ...interface{}) (err *Error) {
  if message == "" {
    message = ErrMessages[code]
  }
  return &Error{
    Code:    code,
    Message: message,
    Context: &ctx,
  }
}

func (s *Error) Error() (msg string) {
  return s.Message
}

使用Supervisor来管理服务进程

Supervisor 是一个Python写的进程管理工具,利用它我们可以很方便的管理应用服务的启动、停止和重启,并且可以同时管理多个进程。
通过 supervisord 来启动Supervisor的daemon进程,然后通过 supervisorctl 来连接上daemon进程执行管理操作。

Supervisor配置文件:

[inet_http_server]
port = 127.0.0.1:9001

[supervisord]
nodaemon = true
logfile = /data/zaiqiuchang/server/supervisord.log
pidfile = /data/zaiqiuchang/server/supervisord.pid
childlogdir = /data/zaiqiuchang/server

[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface

[supervisorctl]
serverurl = http://127.0.0.1:9001

[program:server]
command = zqc server --log.level=%(ENV_ZQC_LOG_LEVEL)s --server.debug=%(ENV_ZQC_SERVER_DEBUG)s

从command的配置可以看到前面我们提到过的,应用仅接收命令行参数,不读取环境变量。环境变量将转换成命令行参数传递给应用。

Supervisor管理操作示例:

➜  server git:(master) ✗ supervisorctl
server                           RUNNING   pid 16, uptime 5 days, 1:38:04
supervisor> status
server                           RUNNING   pid 16, uptime 5 days, 1:38:12
supervisor> stop all
server: stopped
supervisor> start all
server: started
supervisor> restart all
server: stopped
server: started
supervisor> ?

default commands (type help <topic>):
=====================================
add    exit      open  reload  restart   start   tail
avail  fg        pid   remove  shutdown  status  update
clear  maintail  quit  reread  signal    stop    version

supervisor>

使用Fswatch来自动部署代码更新

对于Go这类编译型的语言,在修改完代码后需要重新编译代码。开发过程中修改代码是非常频繁的一件事情,如果每次都手动来操作很繁琐,也影响开发效率。Fswatch 是一个文件系统更新监测工具,利用它我们可以监控应用代码目录,在发生修改事件时执行我们的编译和重启脚本来部署代码更新。

启动fswatch监控的脚本:

#!/usr/bin/env bash

source ./common.sh

# 开始先执行一次部署,同时部署开发和测试两套环境
docker-compose -p zqc exec server ./restart.sh
docker-compose -p zqctest exec server ./restart.sh

# 启动检测,为了防止文件修改太过频繁导致部署任务堆积,先将监测到的文件变化写入到临时文件中
fswatch -e ".*" -i "\.go$" -r . >>.fswatch_modified 2>&1 &

# 空闲时每隔1s check一下是否有新的文件变化,部署过程中则不受影响,防止同时触发多个部署任务
while [[ true ]]
do
  if [[ `wc .fswatch_modified | awk {'print $1'}` -gt 0 ]]; then
    # 清空临时文件,表明已在处理中
    cat /dev/null >.fswatch_modified
    # 重新部署
    docker-compose -p zqc exec server ./restart.sh
    docker-compose -p zqctest exec server ./restart.sh
  fi

  sleep 1
done

restart.sh

#!/usr/bin/env bash

source ./common.sh

./build.sh
if [[ $? != 0 ]]; then
  exit 1
fi

supervisorctl restart all

exit 0

build.sh

#!/usr/bin/env bash

source ./common.sh

log INFO "build begin ..."
go install -v . && mv $GOPATH/bin/server $GOPATH/bin/zqc
if [[ $? != 0 ]]; then
  log ERROR "build failed"
  exit 1
fi
log INFO "build ok"

exit 0

其中我们在代码有更新时通过 docker-compose 命令在容器内执行 restart.sh 脚本来重新编译应用和重启应用服务,包括开发和测试两套环境。关于Docker部署在本系列的“部署篇”会讲解。

实际执行效果如下:

➜  server git:(master) ✗ ./fswatch.sh
// 启动时执行编译和重启
e027a77ca24f 2016-07-08 15:19:53 INFO build begin ...
zaiqiuchang.com/server
e027a77ca24f 2016-07-08 15:19:57 INFO build ok
server: stopped
server: started
4d04a2a0db7c 2016-07-08 15:20:00 INFO build begin ...
zaiqiuchang.com/server
4d04a2a0db7c 2016-07-08 15:20:04 INFO build ok
server: stopped
server: started
// 此处发生代码修改
e027a77ca24f 2016-07-08 15:20:32 INFO build begin ...
zaiqiuchang.com/server/services
zaiqiuchang.com/server/controllers
zaiqiuchang.com/server/cmd
zaiqiuchang.com/server
e027a77ca24f 2016-07-08 15:20:39 INFO build ok
server: stopped
server: started
4d04a2a0db7c 2016-07-08 15:20:41 INFO build begin ...
zaiqiuchang.com/server/services
zaiqiuchang.com/server/controllers
zaiqiuchang.com/server/cmd
zaiqiuchang.com/server
4d04a2a0db7c 2016-07-08 15:20:46 INFO build ok
server: stopped
server: started

打印完整的请求和响应来辅助调试

Echo框架默认打印的请求日志里没有POST请求的提交内容,也没有打印响应内容,在调试API时很不方便。我们实现了一个打印请求和响应的中间件,该中间件只有在Debug模式下才会加载,启用后将打印每个请求的提交内容和响应结果。

打印请求和响应的Echo中间件:

// middlewares/reqresplogger.go

package middlewares

import (
  "os"
  "path/filepath"

  log "github.com/Sirupsen/logrus"
  "github.com/labstack/echo"
  "github.com/spf13/viper"
)

func ReqRespLogger() echo.MiddlewareFunc {
  logger := log.New()
  logger.Formatter = &log.JSONFormatter{}

  lvl, err := log.ParseLevel(viper.GetString("log.level"))
  if err != nil {
    panic(err)
  }
  logger.Level = lvl

  w, err := os.OpenFile(filepath.Join(viper.GetString("dir.data"), viper.GetString("log.reqresp.file")), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0640)
  if err != nil {
    panic(err)
  }
  logger.Out = w

  return func(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) (err error) {
      req := c.Request()
      resp := c.Response()
      if err = next(c); err != nil {
        c.Error(err)
      }
      logger.WithFields(map[string]interface{}{
        "req": map[string]interface{}{
          "method":     req.Method(),
          "uri":        req.URI(),
          "header":     req.Header(),
          "formParams": req.FormParams(),
        },
        "resp": map[string]interface{}{
          "status": resp.Status(),
          "header": resp.Header(),
          "body":   c.Get("respBody"),
        },
      }).Debug("request and response content")
      return err
    }
  }
}