响应输出(Responses)
本章将介绍如何使用响应器(responder)向请求的客户端返回响应结果。
简介
在正式的应用或系统中,稳定可靠的响应结构是服务器与客户端沟通的桥梁,也是数据交换的基石。Go-Sail 内置的响应器正是为了实现这一目标,为服务器提供稳定的响应输出,在复杂中追求有序。
约定
在 Go-Sail 生态中,标准化的响应结构采用 JSON 数据格式。这也是绝大多数现代应用的首选格式。不过请注意,这并非强制要求,开发者可按需决定。
创建响应
你可能在前面章节已经注意到,Go-Sail 非常容易生成响应,例如:
package main
import (
"time"
"github.com/gin-gonic/gin"
"github.com/keepchen/go-sail/v3/sail"
"github.com/keepchen/go-sail/v3/sail/config"
"github.com/keepchen/go-sail/v3/lib/db"
"github.com/keepchen/go-sail/v3/constants"
)
var (
privateKey = []byte(`-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDUvUDx+LPQ0S+L
+5UmtD2EJw1L953mVCMWBJktBbqPTIhDmrd33+3cNq0t7rXuALhoqZS/53nDchU1
wsCveieNDR7SsdO4HMS4bnxgyuYCkC1ugAdyvJ2FCv7xUppc7PvyIQ1gQS/nOP0w
...
vplU0p7ayaXuNF2t73k/L5f92+8VBuYECEUOXw2xST5gvkPdKGK1xM1cLT6y8TrF
RIXvUK2duHjDxiaPKtANi2P4
-----END PRIVATE KEY-----`)
publicKey = []byte(`-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1L1A8fiz0NEvi/uVJrQ9
...
KTJQ+GGzUqOGzruYQ5sM3TnU8Avb4OF36uyADBwA4bP944tKSNSET7BC3N0UerRo
QwIDAQAB
-----END PUBLIC KEY-----`)
conf = &config.Config{
DBConf: db.Conf{
Enable: true,
DriverName: "mysql",
Mysql: db.MysqlConf{
Read: db.MysqlConfItem{
Host: "127.0.0.1",
Port: 3306,
Username: "root",
Password: "root",
Database: "go_sail",
},
Write: db.MysqlConfItem{
Host: "127.0.0.1",
Port: 3306,
Username: "root",
Password: "root",
Database: "go_sail",
},
},
Logger: db.Logger{
Level: "warn",
SlowThreshold: 100,
SkipCallerLookup: true,
IgnoreRecordNotFoundError: true,
Colorful: false,
},
NowFunc: func() time.Time {
return time.Now().In(time.UTC)
},
},
}
registerRoutes = func(ginEngine *gin.Engine) {
ginEngine.POST("/login", func(c *gin.Context){
var loginRequest LoginRequest
c.ShouldBind(&loginRequest)
var user User
sail.GetDBR().Where(&User{Username: loginRequest.Username}).First(&user)
// 用户不存在
if len(loginRequest.Username) == 0 {
sail.Response(c).Wrap(constants.ErrRequestParamsInvalid, nil).Send()
return
}
// 密码错误
if loginRequest.Password != user.Password {
sail.Response(c).Wrap(constants.ErrRequestParamsInvalid, nil).Send()
return
}
token := "this-is-a-valid-token"
sail.Response(c).Data(token)
})
userGroup := ginEngine.Group("/user").Use(ValidateToken())
{
userGroup.GET("/balance", ...).
GET("/info", ...).
GET("/logout", ...)
}
}
afterFunc = func() {
sail.GetDBW().AutoMigrate(&User)
var user User
sail.GetDBW().Where(&User{Username:"go-sail"}).First(&user)
if len(user.Username) == 0 {
passwordEncrypted, err := sail.Utils().RSA().Encrypt("password", publicKey)
sail.GetDBW().Create(&User{Username:"go-sail", password: passwordEncrypted})
}
}
)
func main() {
sail.WakeupHttp("go-sail", conf).Hook(registerRoutes, nil, afterFunc).Launch()
}
HTTP 状态码
HTTP 状态码是约定俗成的标准,但不同团队或公司实际执行上各有规则,有的严格按照分类,有的则除少量特殊状态(如 200、401、403、404、500、502、504 等)外统一处理。
实际上两者各有利弊。Go-Sail 不参与这一争论,也不会强制约束,每个团队可自行选用。
Go-Sail 从设计之初就非常重视错误码理念,并主张将其和 HTTP 状态码结合。原因是错误码能更细致表达异常。比如 HTTP 400,错误码 100000 可能代表入参类型错,100001 代表字段值不可信。结合国际化错误信息,能提升系统交互友好度。
显式错误码
错误码是 Go-Sail 响应体系非常重要的一环。Go-Sail 也倡导显式错误码,这有助于代码维护和健壮性。
Go-Sail 生态内部分错误码和 HTTP 状态码预先关联如下:
-
ErrNone (0) 表示没有错误,对应 HTTP 状态码 200。
-
ErrRequestParamsInvalid (100000) 请求参数错误,对应 HTTP 状态码 400。
-
ErrAuthorizationTokenInvalid (100001) 认证 Token 失效,对应 HTTP 状态码 401。
-
ErrInternalServerError (999999) 服务内部错误,对应 HTTP 状态码 500。
这些默认值开发者都可以根据实际条件进行覆写。可以在程序启动时通过如下方式配置:
package main
import (
"time"
"github.com/gin-gonic/gin"
"github.com/keepchen/go-sail/v3/sail"
"github.com/keepchen/go-sail/v3/sail/config"
"github.com/keepchen/go-sail/v3/lib/db"
"github.com/keepchen/go-sail/v3/http/api"
"github.com/keepchen/go-sail/v3/constants"
)
var (
privateKey = []byte(`-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDUvUDx+LPQ0S+L
+5UmtD2EJw1L953mVCMWBJktBbqPTIhDmrd33+3cNq0t7rXuALhoqZS/53nDchU1
wsCveieNDR7SsdO4HMS4bnxgyuYCkC1ugAdyvJ2FCv7xUppc7PvyIQ1gQS/nOP0w
...
vplU0p7ayaXuNF2t73k/L5f92+8VBuYECEUOXw2xST5gvkPdKGK1xM1cLT6y8TrF
RIXvUK2duHjDxiaPKtANi2P4
-----END PRIVATE KEY-----`)
publicKey = []byte(`-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1L1A8fiz0NEvi/uVJrQ9
...
KTJQ+GGzUqOGzruYQ5sM3TnU8Avb4OF36uyADBwA4bP944tKSNSET7BC3N0UerRo
QwIDAQAB
-----END PUBLIC KEY-----`)
conf = &config.Config{
DBConf: db.Conf{
Enable: true,
DriverName: "mysql",
Mysql: db.MysqlConf{
Read: db.MysqlConfItem{
Host: "127.0.0.1",
Port: 3306,
Username: "root",
Password: "root",
Database: "go_sail",
},
Write: db.MysqlConfItem{
Host: "127.0.0.1",
Port: 3306,
Username: "root",
Password: "root",
Database: "go_sail",
},
},
Logger: db.Logger{
Level: "warn",
SlowThreshold: 100,
SkipCallerLookup: true,
IgnoreRecordNotFoundError: true,
Colorful: false,
},
NowFunc: func() time.Time {
return time.Now().In(time.UTC)
},
},
}
registerRoutes = func(ginEngine *gin.Engine) {
ginEngine.POST("/login", func(c *gin.Context){
var loginRequest LoginRequest
c.ShouldBind(&loginRequest)
var user User
sail.GetDBR().Where(&User{Username: loginRequest.Username}).First(&user)
// 用户不存在
if len(loginRequest.Username) == 0 {
sail.Response(c).Wrap(constants.ErrRequestParamsInvalid, nil).Send()
return
}
// 密码错误
if loginRequest.Password != user.Password {
sail.Response(c).Wrap(constants.ErrRequestParamsInvalid, nil).Send()
return
}
token := "this-is-a-valid-token"
sail.Response(c).Data(token)
})
userGroup := ginEngine.Group("/user").Use(ValidateToken())
{
userGroup.GET("/balance", ...).
GET("/info", ...).
GET("/logout", ...)
}
}
afterFunc = func() {
sail.GetDBW().AutoMigrate(&User)
var user User
sail.GetDBW().Where(&User{Username:"go-sail"}).First(&user)
if len(user.Username) == 0 {
passwordEncrypted, err := sail.Utils().RSA().Encrypt("password", publicKey)
sail.GetDBW().Create(&User{Username:"go-sail", password: passwordEncrypted})
}
}
)
func main() {
options := &api.Option{
ErrNoneCode: 200,
ErrRequestParamsInvalidCode: 2000,
ErrAuthorizationTokenInvalidCode: 3000,
ErrInternalServerErrorCode: 4000,
}
sail.WakeupHttp("go-sail", conf).
SetupApiOption(options).
Hook(registerRoutes, nil, afterFunc).Launch()
}
这样,响应器将自动根据错误码推断出正确的 HTTP 状态码进行响应。例如,错误码 2000 对应 HTTP 400,错误码 3000 对应 HTTP 401。
同时,Go-Sail 的响应器也支持覆写错误码的消息描述,使其更灵活:
func main() {
options := &api.Option{
ErrNoneCode: 200,
ErrNoneCodeMsg: "酷!",
ErrRequestParamsInvalidCode: 2000,
ErrRequestParamsInvalidCodeMsg: "请检查你的输入。",
ErrAuthorizationTokenInvalidCode: 3000,
ErrAuthorizationTokenInvalidCodeMsg: "你是谁?",
ErrInternalServerErrorCode: 4000,
ErrInternalServerErrorCodeMsg: "糟糕~ 出现了点小问题。",
}
sail.WakeupHttp("go-sail", conf).
SetupApiOption(options).
Hook(registerRoutes, nil, afterFunc).Launch()
}
强制状态码
如果你的团队决定所有响应都用 HTTP 200 状态码,具体细分用错误码表达,可以这样设置:
package main
import (
"time"
"github.com/gin-gonic/gin"
"github.com/keepchen/go-sail/v3/sail"
"github.com/keepchen/go-sail/v3/sail/config"
"github.com/keepchen/go-sail/v3/lib/db"
"github.com/keepchen/go-sail/v3/http/api"
"github.com/keepchen/go-sail/v3/constants"
)
var (
privateKey = []byte(`-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDUvUDx+LPQ0S+L
+5UmtD2EJw1L953mVCMWBJktBbqPTIhDmrd33+3cNq0t7rXuALhoqZS/53nDchU1
wsCveieNDR7SsdO4HMS4bnxgyuYCkC1ugAdyvJ2FCv7xUppc7PvyIQ1gQS/nOP0w
...
vplU0p7ayaXuNF2t73k/L5f92+8VBuYECEUOXw2xST5gvkPdKGK1xM1cLT6y8TrF
RIXvUK2duHjDxiaPKtANi2P4
-----END PRIVATE KEY-----`)
publicKey = []byte(`-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1L1A8fiz0NEvi/uVJrQ9
...
KTJQ+GGzUqOGzruYQ5sM3TnU8Avb4OF36uyADBwA4bP944tKSNSET7BC3N0UerRo
QwIDAQAB
-----END PUBLIC KEY-----`)
conf = &config.Config{
DBConf: db.Conf{
Enable: true,
DriverName: "mysql",
Mysql: db.MysqlConf{
Read: db.MysqlConfItem{
Host: "127.0.0.1",
Port: 3306,
Username: "root",
Password: "root",
Database: "go_sail",
},
Write: db.MysqlConfItem{
Host: "127.0.0.1",
Port: 3306,
Username: "root",
Password: "root",
Database: "go_sail",
},
},
Logger: db.Logger{
Level: "warn",
SlowThreshold: 100,
SkipCallerLookup: true,
IgnoreRecordNotFoundError: true,
Colorful: false,
},
NowFunc: func() time.Time {
return time.Now().In(time.UTC)
},
},
}
registerRoutes = func(ginEngine *gin.Engine) {
ginEngine.POST("/login", func(c *gin.Context){
var loginRequest LoginRequest
c.ShouldBind(&loginRequest)
var user User
sail.GetDBR().Where(&User{Username: loginRequest.Username}).First(&user)
// 用户不存在
if len(loginRequest.Username) == 0 {
sail.Response(c).Wrap(constants.ErrRequestParamsInvalid, nil).Send()
return
}
// 密码错误
if loginRequest.Password != user.Password {
sail.Response(c).Wrap(constants.ErrRequestParamsInvalid, nil).Send()
return
}
token := "this-is-a-valid-token"
sail.Response(c).Data(token)
})
userGroup := ginEngine.Group("/user").Use(ValidateToken())
{
userGroup.GET("/balance", ...).
GET("/info", ...).
GET("/logout", ...)
}
}
afterFunc = func() {
sail.GetDBW().AutoMigrate(&User)
var user User
sail.GetDBW().Where(&User{Username:"go-sail"}).First(&user)
if len(user.Username) == 0 {
passwordEncrypted, err := sail.Utils().RSA().Encrypt("password", publicKey)
sail.GetDBW().Create(&User{Username:"go-sail", password: passwordEncrypted})
}
}
)
func main() {
options := &api.Option{
ForceHttpCode200: true,
}
sail.WakeupHttp("go-sail", conf).
SetupApiOption(options).
Hook(registerRoutes, nil, afterFunc).Launch()
}
这样设置后,响应器不再根据错误码推断 HTTP 状态码,而是始终返回 200。但如果有特殊需要,也可以临时指定响应状态码:
sail.Response(c).Wrap(...).SendWithCode(403)
此方法优先级最高,不受错误码或全局配置限制。
错误码注册
开发者可以根据业务需要,将自定义错误码注册到 Go-Sail 的错误码容器中,后续响应即可直接使用。
强烈建议用常量进行注册,这样可读性和可维护性最高。
type ErrorCode int
func (v ErrorCode) Int() int {
return int(v)
}
const (
ErrUserNotExist ErrorCode = 1000
ErrUserAlreadyExist ErrorCode = 1001
ErrUsernameAndPasswordNotMatch ErrorCode = 1002
)
var codeMsgMap = map[ErrorCode]string{
ErrUserNotExist: "用户不存在",
ErrUserAlreadyExist: "用户已存在",
ErrUsernameAndPasswordNotMatch: "用户名或密码错误",
}
var once sync.Once
func init() {
once.Do(func() {
time.AfterFunc(time.Second*2, func() {
for code, msg := range codeMsgMap {
sail.Code().Register("zh", code.Int(), msg)
}
})
})
}
使用自定义错误码
之后就可以像这样进行响应:
sail.Response(c).Bundle(ErrUserNotExist.Int(), nil).Send()
三种包装器
Go-Sail 提供三种不同的响应包装器(wrapper):
- Builder
- Wrap
- Bundle
它们适用于不同场景,下面逐一介绍。
假定有如下数据结构定义:
import "github.com/keepchen/go-sail/v3/http/pojo/dto"
type UserInfo struct {
dto.Base
Data struct {
Nickname string `json:"nickname" validate:"required" format:"string"`
Age number `json:"nickname" validate:"required" format:"number"`
} `json:"data" validate:"required" format:"object"`
}
func (v UserInfo) GetData() interface{} {
return v.Data
}
type SimpleUser struct {
Nickname string `json:"nickname" validate:"required" format:"string"`
Age number `json:"nickname" validate:"required" format:"number"`
}
Builder
Builder 参数的错误码类型需要为 Go-Sail 的 constants.ICodeType,响应数据类型为 dto.IResponse。
var userInfo UserInfo
sail.Response(c).Builder(constants.XX, userInfo).Send()
Wrap
Wrap 错误码类型同样为 constants.ICodeType,但响应数据类型为 interface,在高版本 Golang 里也是 any。
var userInfo SimpleUser
sail.Response(c).Wrap(constants.XX, userInfo).Send()
Bundle
Bundle 错误码为 int 类型,响应数据为 interface 或 any。Bundle 语法最简单易用。
var userInfo SimpleUser
sail.Response(c).Bundle(200, userInfo).Send()
三者本质没差别,主要是语法糖封装松紧度不同。
至于 dto.Base 的组合,是为满足有的开发团队要求接口文档每个响应都显示“固定结构”;另一类团队只需展示精炼的数据部分,两种规范都能兼容。
固定结构
固定结构是 Go-Sail 响应器设计的稳定输出结构,固定字段可直接参考 http/pojo/dto/base.go:
type Base struct {
// in: body
// required: true
RequestID string `json:"requestId" example:"5686efa5-c747-4f63-8657-e6052f8181a9" format:"string" validate:"required"`
// in: body
// required: true
Code int `json:"code" format:"int" example:"0" validate:"required"`
// in: body
// required: true
Success bool `json:"success" example:"true" format:"bool" validate:"required"`
// in: body
// required: true
Message string `json:"message" example:"SUCCESS" format:"string" validate:"required"`
// in: body
// required: true
Timestamp int64 `json:"ts" example:"1670899688591" format:"int64" validate:"required"`
// in: body
// required: true
Data any `json:"data" format:"object|array|string|number|boolean" validate:"required"`
}
响应器始终会以这种结构输出,实际业务数据放在 data 字段里,其余字段自动维护,不受业务代码影响。
最终输出格式大致如下:
{
"code": 0,
"data": null,
"message": "SUCCESS",
"requestId": "5686efa5-c747-4f63-8657-e6052f8181a9",
"success": true,
"ts": 1670899688591
}
更多用法请见 响应器示例
非固定结构使用场景
有时需要响应非固定结构的数据(如对接支付等第三方系统),这时可直接用 gin.Context 的原生响应方法,无须使用 Go-Sail 的响应器。
package main
import (
"time"
"github.com/gin-gonic/gin"
"github.com/keepchen/go-sail/v3/sail"
"github.com/keepchen/go-sail/v3/sail/config"
"github.com/keepchen/go-sail/v3/lib/db"
"github.com/keepchen/go-sail/v3/http/api"
"github.com/keepchen/go-sail/v3/constants"
)
var (
privateKey = []byte(`-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDUvUDx+LPQ0S+L
+5UmtD2EJw1L953mVCMWBJktBbqPTIhDmrd33+3cNq0t7rXuALhoqZS/53nDchU1
wsCveieNDR7SsdO4HMS4bnxgyuYCkC1ugAdyvJ2FCv7xUppc7PvyIQ1gQS/nOP0w
...
vplU0p7ayaXuNF2t73k/L5f92+8VBuYECEUOXw2xST5gvkPdKGK1xM1cLT6y8TrF
RIXvUK2duHjDxiaPKtANi2P4
-----END PRIVATE KEY-----`)
publicKey = []byte(`-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1L1A8fiz0NEvi/uVJrQ9
...
KTJQ+GGzUqOGzruYQ5sM3TnU8Avb4OF36uyADBwA4bP944tKSNSET7BC3N0UerRo
QwIDAQAB
-----END PUBLIC KEY-----`)
conf = &config.Config{
DBConf: db.Conf{
Enable: true,
DriverName: "mysql",
Mysql: db.MysqlConf{
Read: db.MysqlConfItem{
Host: "127.0.0.1",
Port: 3306,
Username: "root",
Password: "root",
Database: "go_sail",
},
Write: db.MysqlConfItem{
Host: "127.0.0.1",
Port: 3306,
Username: "root",
Password: "root",
Database: "go_sail",
},
},
Logger: db.Logger{
Level: "warn",
SlowThreshold: 100,
SkipCallerLookup: true,
IgnoreRecordNotFoundError: true,
Colorful: false,
},
NowFunc: func() time.Time {
return time.Now().In(time.UTC)
},
},
}
registerRoutes = func(ginEngine *gin.Engine) {
ginEngine.POST("/login", func(c *gin.Context){
var loginRequest LoginRequest
c.ShouldBind(&loginRequest)
var user User
sail.GetDBR().Where(&User{Username: loginRequest.Username}).First(&user)
// 用户不存在
if len(loginRequest.Username) == 0 {
sail.Response(c).Wrap(constants.ErrRequestParamsInvalid, nil).Send()
return
}
// 密码错误
if loginRequest.Password != user.Password {
sail.Response(c).Wrap(constants.ErrRequestParamsInvalid, nil).Send()
return
}
token := "this-is-a-valid-token"
sail.Response(c).Data(token)
})
userGroup := ginEngine.Group("/user").Use(ValidateToken())
{
userGroup.GET("/balance", ...).
GET("/info", ...).
GET("/logout", ...)
}
ginEngine.POST("/third-party/notify", func(c *gin.Context){
c.JSON(200, ...)
})
}
afterFunc = func() {
sail.GetDBW().AutoMigrate(&User)
var user User
sail.GetDBW().Where(&User{Username:"go-sail"}).First(&user)
if len(user.Username) == 0 {
passwordEncrypted, err := sail.Utils().RSA().Encrypt("password", publicKey)
sail.GetDBW().Create(&User{Username:"go-sail", password: passwordEncrypted})
}
}
)
func main() {
options := &api.Option{
ForceHttpCode200: true,
}
sail.WakeupHttp("go-sail", conf).
SetupApiOption(options).
Hook(registerRoutes, nil, afterFunc).Launch()
}