この記事は、Wano Group Advent Calendar 2024の2日目の記事です。
1日目は、@_tachi_さんの、[Git] gitconfig 活用しよう
グループ会社のEDOCODEもAdvent Calendarをやっていますので、そちらもどうぞ。ちなみに、そっちにも実際のアクセスに近づけたAPIの負荷テストを行うという記事を本日書いています。
エラーレスポンス用のコードについて考える
500 エラーとか、400エラーとか、そういうのを返すコードです。基本的には、メッセージを変えるだけでコード自体は同じものになりがちで、コピペしまくる...ということになりがちですね。
また、どういうときに、500エラーがでて、どんなメッセージがでるのか、とか、もしかしたら資料として求められるかもしれません。
であれば、最初から資料として使えるファイル(TSV)をもとにコードを作れば良いんじゃない?と思いました。
で、後で変えたくなったときにも、TSVを変えるだけでコードも変更されるなら、コードとドキュメントに乖離もでないし良いんじゃない?ということでやってみました。
TSVを用意する
例えばこんな感じです。
name status_code take_error take_arg code message
Internal 500 1 0 5000 Internal Server Error
BadRequest 400 1 0 4000 Bad Request
InvalidApiKey 401 0 1 4000 Invalid API Key
UnauthorizeddApiKey 401 0 1 4001 Unauthorized API Key
MissingApiKey 400 0 0 4002 X-API-Key is missing in header
項目 | 意味 |
---|---|
name | Errorの名前 |
status_code | HTTPレスポンスのステータスコード |
take_error | errorを引数に取るか |
take_arg | その他の引数を取るか |
code | エラーレスポンスに含めるコード |
message | エラーレスポンスに含めるメッセージ |
生成するコード
TSVファイルを読み込んでコードを自動生成するためのコードです。
package main
import (
"bytes"
"encoding/csv"
"log"
"os"
"regexp"
"text/template"
"github.com/jszwec/csvutil"
)
type Def struct {
Name string `csv:"name"`
TakeError int `csv:"take_error"`
TakeStringArg int `csv:"take_arg"`
StatusCode int `csv:"status_code"`
Code string `csv:"code"`
Message string `csv:"message"`
}
func main() {
tsvPath := os.Args[1]
goPath := os.Args[2]
defs, err := parseTsv[Def](tsvPath)
if err != nil {
log.Fatalf(err.Error())
}
data, err := os.ReadFile(goPath)
if err != nil {
log.Fatalf(err.Error())
}
content := string(data)
generated := ""
for _, def := range *defs {
templ := `const {{.Name}}StatusCode = {{.StatusCode}}
const {{.Name}}ErrorCode = "{{.Code}}"
func (c *CustomContext) Err{{.Name}}({{if .TakeStringArg}}message string{{end}}{{if .TakeError}}{{if .TakeStringArg}}, {{end}}e error{{end}}) error {
{{- if .TakeError}}
warnCalled(e)
{{- end -}}
{{- if or .TakeError .TakeStringArg -}}
{{if and .TakeError .TakeStringArg }}
{{- if and (gt .StatusCode 499) (lt .StatusCode 600) }}
// 適当に変えてください
if os.Getenv("APP_ENV") != "production" {
message = message + ", " + e.Error()
}
{{- end -}}
{{else if .TakeError}}
message := ": "+e.Error()
if config.NewAppEnv().IsProduction() {
message = ""
}
{{- else if .TakeStringArg }}
message = ": "+message
{{- end}}
{{- if and (gt .StatusCode 499) (lt .StatusCode 600) }}
log.Warn(message)
{{- end }}
return c.Status({{.Name}}StatusCode).JSON(NewErrorResponse({{.Name}}ErrorCode, "{{.Message}}"+message))
{{- else}}
return c.Status({{.Name}}StatusCode).JSON(NewErrorResponse({{.Name}}ErrorCode, "{{.Message}}"))
{{- end}}
}
`
t, err := template.New("goCode").Parse(templ)
if err != nil {
log.Panic(err)
}
initialBytes := []byte("")
buf := bytes.NewBuffer(initialBytes)
err = t.Execute(buf, def)
if err != nil {
log.Panic(err)
}
generated += buf.String()
}
re := regexp.MustCompile("(?s)(\n//go:generate go run .+?\n)(.*)$")
newContent := re.ReplaceAllString(content, "$1"+"\n"+generated+"\n")
err = os.WriteFile(goPath, []byte(newContent), 0644)
if err != nil {
log.Panicf("failed writing file: %s", err)
}
}
// このコードはutilsというpackageに入っていたもので、これ専用ではありません
func parseTsv[T any](path string) (*[]T, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
csvReader := csv.NewReader(f)
csvReader.Comma = '\t'
dec, err := csvutil.NewDecoder(csvReader)
if err != nil {
log.Fatal(err)
}
defs := &[]T{}
if err := dec.Decode(&defs); err != nil {
return nil, err
}
return defs, nil
}
生成されるコードを含めるファイル
以下のファイル内に、下記を記載しています。
//go:generate go run ../../codegen/context-errors/main.go ../../docs/context-errors/errors.tsv ./generated_err.go
これで、go generate
で生成できます。
package context
import (
"encoding/json"
"regexp"
"runtime"
"github.com/gofiber/fiber/v2/log"
)
type ErrorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
}
// Implement MarshalJSON for ErrorResponse
func (e ErrorResponse) MarshalJSON() ([]byte, error) {
type Alias ErrorResponse
return json.Marshal(&struct {
Alias
}{
Alias: (Alias)(e),
})
}
func NewErrorResponse(code string, message string) ErrorResponse {
return ErrorResponse{
Code: code,
Message: message,
}
}
func warnCalled(e error) {
isErrOutputed := false
for i := range []int{2, 3, 4, 5} {
pc, file, line, ok := runtime.Caller(i)
if ok {
// `your-path`は適当に変えてください
r := regexp.MustCompile("^.+?your-path/")
file = r.ReplaceAllString(file, "your-path/")
fn := runtime.FuncForPC(pc)
if fn != nil {
if i == 2 {
isErrOutputed = true
log.Warnf("%s called from %s in %s at %d", e.Error(), fn.Name(), file, line)
} else {
log.Warnf(" called from %s in %s at %d", fn.Name(), file, line)
}
}
}
}
if !isErrOutputed {
log.Warn(e.Error())
}
}
// DON"T EDIT THE FLLOWING LINES
//go:generate go run ../../codegen/context-errors/main.go ../../docs/context-errors/errors.tsv ./generated_err.go
生成されたコード
以下のようなコードが生成されます。
const InternalStatusCode = 500
const InternalErrorCode = "5000"
func (c *CustomContext) ErrInternal(e error) error {
warnCalled(e)
message := ": " + e.Error()
if config.NewAppEnv().IsProduction() {
message = ""
}
log.Warn(message)
return c.Status(InternalStatusCode).JSON(NewErrorResponse(InternalErrorCode, "Internal Server Error"+message))
}
嬉しいところ
最初に書いた理由以外にも、いくつかあります。
テストに使える
例えば、テストを書いていたとして、(ないと思いますが)ErrInternal
のHTTPのStatus Codeを変えたくなったとします。
そんな場合、テストコード上に、500
と書いて比較するのではなく、生成されたコードのconstのInternalStatusCode
を使えば、定義を変えてもテストは壊れません。
決めなきゃいけないことを後回しにできる
こういうコードとかは、後で変えてもそんなに問題がないけど、一旦決めとかないと開発進めにくいというものでもあるかなと思います。決めるものは少ないほうが良いので、一旦適当に決めておいて、後から真面目に考えて変更しても、破綻しないというのも、まぁ、良いんじゃないかなと思っています。
自動生成コードを変更すれば一気に変えられる
当たり前ですが、コピペだと面倒ですね。例えば、今回だと、以下のような変更をあと付けで行いました。
- productionならエラーメッセージをレスポンスに含めない
- 呼び出し元をログにだす
このような機能を一括で追加したいときにも便利かもしれません(関数化したら良いやんという話もあるかもしれません)。
終わり
以上、TSVからエラーレスポンス用のコードを自動生成する方法でした。ただ、一回適当に決めてしまうと、特に変えるモチベーションもわかず、そのまま使っているというオチ...だったりもします。
明日も...自分になってしまいましたが、「フルスクラッチでプロジェクトを始めるときにやっていること」になります。