跳到主要内容

响应输出(Responses)

本章将介绍如何使用响应器(responder)向请求的客户端返回响应结果。

简介

在正式的应用或系统中,稳定可靠的响应结构是服务器与客户端沟通的桥梁,也是数据交换的基石。Go-Sail 内置的响应器正是为了实现这一目标,为服务器提供稳定的响应输出,在复杂中追求有序。

约定

在 Go-Sail 生态中,标准化的响应结构采用 JSON 数据格式。这也是绝大多数现代应用的首选格式。不过请注意,这并非强制要求,开发者可按需决定。

创建响应

你可能在前面章节已经注意到,Go-Sail 非常容易生成响应,例如:

main.go
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

这些默认值开发者都可以根据实际条件进行覆写。可以在程序启动时通过如下方式配置:

main.go
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 的响应器也支持覆写错误码的消息描述,使其更灵活:

main.go
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 状态码,具体细分用错误码表达,可以这样设置:

main.go
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 的错误码容器中,后续响应即可直接使用。

提示

强烈建议用常量进行注册,这样可读性和可维护性最高。

main.go
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)
}
})
})
}

使用自定义错误码

之后就可以像这样进行响应:

main.go
sail.Response(c).Bundle(ErrUserNotExist.Int(), nil).Send()

三种包装器

Go-Sail 提供三种不同的响应包装器(wrapper):

  • Builder
  • Wrap
  • Bundle

它们适用于不同场景,下面逐一介绍。

假定有如下数据结构定义:

main.go
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

main.go
var userInfo UserInfo
sail.Response(c).Builder(constants.XX, userInfo).Send()

Wrap

Wrap 错误码类型同样为 constants.ICodeType,但响应数据类型为 interface,在高版本 Golang 里也是 any

main.go
var userInfo SimpleUser
sail.Response(c).Wrap(constants.XX, userInfo).Send()

Bundle

Bundle 错误码为 int 类型,响应数据为 interfaceany。Bundle 语法最简单易用。

main.go
var userInfo SimpleUser
sail.Response(c).Bundle(200, userInfo).Send()

三者本质没差别,主要是语法糖封装松紧度不同。

至于 dto.Base 的组合,是为满足有的开发团队要求接口文档每个响应都显示“固定结构”;另一类团队只需展示精炼的数据部分,两种规范都能兼容。

固定结构

固定结构是 Go-Sail 响应器设计的稳定输出结构,固定字段可直接参考 http/pojo/dto/base.go

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 的响应器。

main.go
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()
}