個人的に最強なGolang開発用テンプレート

2024/09/07

Golangを1年弱ほど触り、いろいろな開発をしているうちに結構よく使う構造とライブラリが顕著なことがわかってきました。

そこで今回は自分が作成したテンプレートを載せてみようと思います。自分が立ち上げたGoのプロジェクトだと殆ど利用していると思います。

なお、今回のテンプレートはこちらで公開されています。

概要

引数解析にはCobraCobraUtilsを利用します。

エラーハンドリングにはcockroachdb/errorsを、ロギングにはlog/slogm-mizutani/clogを用います。

Cobraはサブコマンドを実装するためのライブラリです。cobra.Commandという構造体のインスタンスを生成することで各サブコマンドを作成します。

公式で用意されているサンプルは可読性が低い上にテストを実装しにくいため、様々な試行錯誤を行った結果現在では自前のラッパーを利用した形式に落ち着いています。

ロギングは標準ライブラリであるlog/slogと、slogを見やすい形式にしてくれるライブラリであるm-mizutani/clogを利用します。

全体

例えば、helloコマンドとbyeを実装しする場合、概ね以下のような形式になります。

今回はモジュール名をmyprojectとします。

├── cmd
│   ├── hello
│   │   └── cmd.go
│   ├── bye
│   │   └── cmd.go
│   ├── hello.go
│   ├── bye.go
│   ├── exe.go
│   └── root.go
├── log
│   └── log.go
└── main.go

main

mainに配置するのはmain.goのみです。

/main.go

main.goは主にCobraをラップしたcmdパッケージの起動と最終的なエラーハンドリング、ログ設定を行います。

エラーの出力をこのように行っている理由については後述します。

package main
 
import (
    "fmt"
    "os"
 
    "myproject/cmd"
    _ "myproject/log"
)
 
func main() {
    if err := cmd.Execute(); err != nil {
    fmt.Fprintf(os.Stderr, "%+v\n", err)
    os.Exit(-1)
    }
}

myproject/log

ここではロギングに関する設定を行いますが、main以外からこのパッケージを呼び出すことはありません。

また、パブリックな関数や変数も一切作成しません。代わりにimportされたときにlog/slogの初期設定を行います。

/log/log.go

package log
 
import (
    "log/slog"
 
    "github.com/m-mizutani/clog"
)
 
func init() {
    handler := clog.New(clog.WithColor(true))
    logger := slog.New(handler)
    slog.SetDefault(logger)
}

slog.Infoslog.Debugのような関数は内部ではデフォルトに設定されたロガーを呼び出しているという実装になっています。

その内包されたロガーはslog.SetDefaultで自由に設定できます。

m-mizutani/clogは、色がついたいい感じのログハンドラを作成してくれるライブラリです。clogによって作成されたハンドラを用いてロガーを作成し、それをslogのデフォルトロガーに設定することで、slog.Info等の呼び出しをclogのハンドラで処理することができるようになります。

更に自前のハンドラを追加したりログレベルを変更したりするなど、slogの設定の変更は全てこの中で行います。

init関数は、そのパッケージがimportされた際に自動で実行される関数で、このモジュールをmain.goから呼び出すことでこの初期化処理を行います。

myproject/cmd

各サブコマンドを実装するパッケージです。

Cobraのテンプレートでは各コマンドをグローバル変数として実装していますが、この方法だとテストを書くのが苦しいのでcobra.Commandのインスタンスを返す関数という形でサブコマンドを実装します。

Cobraにおいてはサブコマンドはcobra.Commandを入れ子にするという形で実装します。そのためまずは全体を統括するrootを実装します。

/cmd/root.go

package cmd
 
import (
    "github.com/Hayao0819/nahi/cobrautils"
    "github.com/spf13/cobra"
)
 
var subCmds = cobrautils.Registory{}
 
func rootCmd() *cobra.Command {
    root := cobra.Command{
        Use:           "go-cobra",
        Short:         "go-cobra command",
        SilenceUsage:  true,
        SilenceErrors: true,
    }
 
    subCmds.Bind(&root)
    return &root
}

/cmd/exe.go

cmdパッケージで唯一パブリックになるのがこのExecute() error関数です。

rootCmd() *cobra.Commandのインスタンスを生成し、Execute()メソッドを実行します。

package cmd
 
func Execute() error {
    return rootCmd().Execute()
}

/cmd/hello/cmd.go, /cmd/bye/cmd.go

これらのファイルでは各コマンドの実体を定義します。

package hello
 
import "github.com/spf13/cobra"
 
func Cmd() *cobra.Command {
    cmd := cobra.Command{
        Use:   "hello",
        Short: "hello command",
        RunE:  func(cmd *cobra.Command, args []string) error {
            // Do something
            cmd.Println("Hello, hoge!")
            return nil
        },
    }
 
    return &cmd
}

どちらのファイルでもCmd() *cobra.Commandをエクスポートしておきます。

サブコマンドを各パッケージに分割することで、cobra.Command.RunEの肥大化を避け、そのサブコマンドでしか利用できないプライベート関数や変数を定義できるようにします。

/cmd/hello.go, /cmd/bye.go

各サブコマンドをrootコマンドに結びつけるグルーコードです。

package cmd
 
import "github.com/Hayao0819/scaffold/go-cobra/cmd/hoge"
 
func init() {
    subCmds.Add(hoge.Cmd())
}

エラーハンドリング

各コマンドでos.Exitを呼び出すようなことは絶対にせず、必ず全てのエラーがrootに返されるように実装します。

具体的には各cobra.Commandに実装される処理はcobra.Command.Runではなくcobra.Command.RunEにします。

各RunEでは、エラーが出た時にそれをWrapしつつreturnします。具体的には以下のようになります。

package hoge
 
import (
    "os"
 
    "github.com/cockroachdb/errors"
    "github.com/spf13/cobra"
)
 
func Cmd() *cobra.Command {
 cmd := cobra.Command{
    Use:   "hoge",
    Short: "hoge command",
    RunE: func(cmd *cobra.Command, args []string) error {
        // Do something
        cmd.Println("Hello, hoge!")
 
        // Handle error
        pwd, err := os.Getwd()
        if err != nil {
            return errors.Wrap(err, "failed to get current directory")
        }
 
        cmd.Println(pwd)
 
       return nil
    },
    }
 
    return &cmd
}

そしてエラーを全てcmd/Execute() errorに帰結させます。

最終的にmain()まで返されたエラーは

fmt.Fprintf(os.Stderr, "%+v\n", err)

で標準エラー出力に出力されます。

cockroachdb/errorsでは%+vでerrを渡すことでスタックトレースが出力されるようになります。

唯一の課題は、スタックトレースとslogを現状併用できないという点です。この点は気が向いたらcockroachdb/errorsにIssueを出してみようと思います。

samber/lo

今回のテンプレートには登場しませんが、頻繁に使用するライブラリがsamber/loです。

ジェネリクスを利用してForEachやMap、Filter等が実装されているユーティリティ系のライブラリです。

自分が一時期Tsをたくさん書いてた時期があるせいか、Go特有のなんでもforを愚直に回すコードよりもloを使ったほうが好きです。

終わり

Goのエラーハンドリングは少々特殊ですが、スタックトレースが表示されるようになると劇的に実装が楽になります。

また、Cobraの実装も人によって差があるようでQiitaやZenn等にいろいろな人のサンプルが実装されています。

私のこの現状の結論も、いろいろな人のものを参考に試行錯誤をしてたどり着いたものです。今後また新しいライブラリやデザインパターン等で変化があった際にはこの記事を更新しようと思います。