レスポンス
このセクションでは、クライアントからのリクエストに対してレスポンスを返すために、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-----
...
-----END PRIVATE KEY-----`)
publicKey = []byte(`-----BEGIN PUBLIC KEY-----
...
-----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など)以外は統一してしまうこともあります。
実際にはどちらにも利点があります。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) 認可トークンの期限切れ(HTTP ステータス 401)
-
ErrInternalServerError (999999) サーバー内部エラー(HTTP ステータス 500)
これらの値は 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-----
...
-----END PRIVATE KEY-----`)
publicKey = []byte(`-----BEGIN PUBLIC KEY-----
...
-----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: "Cool!",
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-----
...
-----END PRIVATE KEY-----`)
publicKey = []byte(`-----BEGIN PUBLIC KEY-----
...
-----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 を応答します。
特別な場合、一時的に異なる HTTP ステータスコードで返したい場合もあります:
sail.Response(c).Wrap(...).SendWithCode(403)
この方法は最優先され、エラーコードや ForceHttpCode200 設定に影響されません。
エラーコードの登録
開発者は自分のビジネス要件に合わせて独自のエラーコードを 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("ja", code.Int(), msg)
}
})
})
}
エラーコードの利用
登録後は以下のように利用できます:
sail.Response(c).Bundle(ErrUserNotExist.Int(), nil).Send()
ラッパー(Wrapper)
Go-Sail では3種類のラッパーを提供しています。
- 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(Go1.18以降では 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 の組み込みは、開発ドキュメントのニーズに応えるためです。すべての API ドキュメントに「固定の構造体」を含めたいチームや、慣習によりデータ部分だけを見せたいチーム、それぞれに合わせた設計となっています。
固定構造体
固定構造体は安定した出力のために 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
}
より多くのサンプルは Responder セクションを参照してください。
非定型レスポンス(Unconventional)
場合によっては、決まったデータ構造ではない形式でレスポンスを返す必要があります。特に、決済系などサードパーティシステム連携時などです。この場合、Go-Sail のレスポンダーを使用せず gin.Context が備えるレスポンス機能を直接使えます。
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-----
...
-----END PRIVATE KEY-----`)
publicKey = []byte(`-----BEGIN PUBLIC KEY-----
...
-----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()
}