メインコンテンツまでスキップ

レスポンス

このセクションでは、クライアントからのリクエストに対してレスポンスを返すために、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-----
...
-----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 で事前登録されていますが、開発者が任意に上書きすることも可能です。プログラム起動時に次のように設定できます:

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-----
...
-----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 のレスポンダーでは、エラーコードに付随するメッセージも上書きできます:

main.go
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)を返すとチームで決めた場合、詳細はエラーコードで判別する運用もできます:

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-----
...
-----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 のエラーコードコンテナに登録し、以降のレスポンス処理で利用できます。

ヒント

エラーコード定義は定数で行うことを強く推奨します。可読性・保守性が向上します。

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("ja", code.Int(), msg)
}
})
})
}

エラーコードの利用​

登録後は以下のように利用できます:

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

ラッパー(Wrapper)​

Go-Sail では3種類のラッパーを提供しています。

  • 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(Go1.18以降では any)です。

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

Bundle​

Bundle ラッパーのエラーコード型は int、データ型は interface(any)です。Bundle は特に手軽に利用できます。

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

本質的な差異はほとんどありません。構文糖衣の適切なラッピングに過ぎず、制約が段階的に緩和されていることが分かります。

dto.Base の組み込みは、開発ドキュメントのニーズに応えるためです。すべての API ドキュメントに「固定の構造体」を含めたいチームや、慣習によりデータ部分だけを見せたいチーム、それぞれに合わせた設計となっています。

固定構造体​

固定構造体は安定した出力のために 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
}
ヒント

より多くのサンプルは Responder セクションを参照してください。

非定型レスポンス(Unconventional)​

場合によっては、決まったデータ構造ではない形式でレスポンスを返す必要があります。特に、決済系などサードパーティシステム連携時などです。この場合、Go-Sail のレスポンダーを使用せず gin.Context が備えるレスポンス機能を直接使えます。

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