8
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Wano GroupAdvent Calendar 2024

Day 2

GoでTSVからエラーレスポンス用のコードを自動生成する

Last updated at Posted at 2024-12-02

この記事は、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を使えば、定義を変えてもテストは壊れません。

決めなきゃいけないことを後回しにできる

こういうコードとかは、後で変えてもそんなに問題がないけど、一旦決めとかないと開発進めにくいというものでもあるかなと思います。決めるものは少ないほうが良いので、一旦適当に決めておいて、後から真面目に考えて変更しても、破綻しないというのも、まぁ、良いんじゃないかなと思っています。

自動生成コードを変更すれば一気に変えられる

当たり前ですが、コピペだと面倒ですね。例えば、今回だと、以下のような変更をあと付けで行いました。

  1. productionならエラーメッセージをレスポンスに含めない
  2. 呼び出し元をログにだす

このような機能を一括で追加したいときにも便利かもしれません(関数化したら良いやんという話もあるかもしれません)。

終わり

以上、TSVからエラーレスポンス用のコードを自動生成する方法でした。ただ、一回適当に決めてしまうと、特に変えるモチベーションもわかず、そのまま使っているというオチ...だったりもします。

明日も...自分になってしまいましたが、「フルスクラッチでプロジェクトを始めるときにやっていること」になります。

8
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?