Chapter 06

ちょこっとリファクタリング

Spiegel
Spiegel
2022.04.03に更新

さて,コードがかなりカオスになってきたので,ここらで一発,リファクタリングをかましておこう。この節は長いよ(笑)

そうそう。今回書いたコードは

$ go mod init sample

で初期化したディレクトリ内にあるので,サブパッケージをインポートする際には

import "sample/env"

みたいな記述になっている。あしからずご了承の程を。

env サブパッケージ

まず env ファイルの中身を以下のようにセットする。

$XDG_CONFIG_HOME/elephantsql/env
ELEPHANTSQL_URL=postgres://username:password@hostname:port/databasename
ENABLE_LOGFILE=true
LOGLEVEL=info

ENABLE_LOGFILEtrue ならファイルに構造化ログを出力し,コンソール(標準出力)へは zerolog.ConsoleWriter を使う。また LOGLEVEL でログの最低出力レベルを指定する。

LOGLEVEL を解釈するために以下の列挙型を導入する。

env/loglevel.go
package env

import (
    "strings"

    "github.com/jackc/pgx/v4"
    "github.com/rs/zerolog"
)

type LoggerLevel int

const (
    LevelNop LoggerLevel = iota
    LevelError
    LevelWarn
    LevelInfo
    LevelDebug
)

var levelMap = map[string]LoggerLevel{
    "nop":   LevelNop,
    "error": LevelError,
    "warn":  LevelWarn,
    "info":  LevelInfo,
    "debug": LevelDebug,
}

var zerologLevelMap = map[LoggerLevel]zerolog.Level{
    LevelNop:   zerolog.NoLevel,
    LevelError: zerolog.ErrorLevel,
    LevelWarn:  zerolog.WarnLevel,
    LevelInfo:  zerolog.InfoLevel,
    LevelDebug: zerolog.DebugLevel,
}

var pgxlogLevelMap = map[LoggerLevel]pgx.LogLevel{
    LevelNop:   pgx.LogLevelNone,
    LevelError: pgx.LogLevelError,
    LevelWarn:  pgx.LogLevelWarn,
    LevelInfo:  pgx.LogLevelInfo,
    LevelDebug: pgx.LogLevelDebug,
}

func getLogLevel(s string) LoggerLevel {
    if lvl, ok := levelMap[s]; ok {
        return lvl
    }
    return LevelInfo
}

func (lvl LoggerLevel) ZerlogLevel() zerolog.Level {
    if l, ok := zerologLevelMap[lvl]; ok {
        return l
    }
    return zerolog.InfoLevel
}

func (lvl LoggerLevel) PgxLogLevel() pgx.LogLevel {
    if l, ok := pgxlogLevelMap[lvl]; ok {
        return l
    }
    return pgx.LogLevelInfo
}

これを踏まえて環境変数の取り出し処理を以下のようにする。

env/env.go
package env

import (
    "os"
    "strings"

    "github.com/jackc/pgx/v4"
    "github.com/joho/godotenv"
    "github.com/rs/zerolog"
    "github.com/goark/gocli/config"
)

const (
    ServiceName = "elephantsql"
)

func init() {
    //load ${XDG_CONFIG_HOME}/${ServiceName}/env file
    if err := godotenv.Load(config.Path(ServiceName, "env")); err != nil {
        //load .env file
        if err := godotenv.Load(); err != nil {
            panic(err)
        }
    }
}

func PostgresDSN() string {
    return os.Getenv("ELEPHANTSQL_URL")
}

func LogLevel() LoggerLevel {
    return getLogLevel(os.Getenv("LOGLEVEL"))
}

func ZerologLevel() zerolog.Level {
    return LogLevel().ZerlogLevel()
}

func PgxlogLevel() pgx.LogLevel {
    return LogLevel().PgxLogLevel()
}

func EnableLogFile() bool {
    return strings.EqualFold(os.Getenv("ENABLE_LOGFILE"), "true")
}

設定ファイルの読み込みに失敗したら既定の .env ファイルで読み直すようにしてみた。

loggr サブパッケージ

環境変数が読めるようになったので logger を生成するパッケージを書こう。パッケージ名が loggr となっているのは,世の中に logger という名前のパッケージがあまりに多いので回避のため(笑)

loggr/loggr.go
package loggr

import (
    "fmt"
    "io"
    "os"
    "sample/env"
    "time"

    "github.com/rs/zerolog"
    "github.com/goark/errs"
    "github.com/goark/gocli/cache"
)

func New() *zerolog.Logger {
    logger := zerolog.Nop()
    if env.ZerologLevel() == zerolog.NoLevel {
        return &logger
    }
    if env.EnableLogFile() {
        // make path to ${XDG_CACHE_HOME}/${ServiceName}/access.YYYYMMDD.log file and create logger
        logpath := cache.Path(env.ServiceName, fmt.Sprintf("access.%s.log", time.Now().Local().Format("20060102")))
        if file, err := os.OpenFile(logpath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600); err != nil {
            logger = zerolog.New(os.Stdout).Level(env.ZerologLevel()).With().Timestamp().Logger()
            logger.Error().Interface("error", errs.Wrap(err)).Str("logpath", logpath).Msg("error in opening logfile")
        } else {
            logger = zerolog.New(io.MultiWriter(
                file,
                zerolog.ConsoleWriter{Out: os.Stdout, NoColor: false},
            )).Level(env.ZerologLevel()).With().Timestamp().Logger()
        }
        return &logger
    }
    logger = zerolog.New(os.Stdout).Level(env.ZerologLevel()).With().Timestamp().Logger()
    return &logger
}

前節までとはログファイルの出力先が異なるのでご注意。

dbconn サブパッケージ

次は github.com/jackc/pgx パッケージを使って接続インスタンスを作るパッケージ。

dbconn/dbconn.go
package dbconn

import (
    "database/sql"
    "sample/env"
    "sample/loggr"

    "github.com/jackc/pgx/v4"
    "github.com/jackc/pgx/v4/log/zerologadapter"
    "github.com/jackc/pgx/v4/stdlib"
    "github.com/rs/zerolog"
    "github.com/goark/errs"
)

type PgxContext struct {
    Db     *sql.DB
    Logger *zerolog.Logger
}

func NewPgx() (*PgxContext, error) {
    dbctx := &PgxContext{
        Logger: loggr.New(),
    }
    cfg, err := pgx.ParseConfig(env.PostgresDSN())
    if err != nil {
        dbctx.Logger.Error().Interface("error", errs.Wrap(err)).Msg("error in pgx.ParseConfig() method")
        return nil, errs.Wrap(err, errs.WithContext("dsn", env.PostgresDSN()))
    }
    cfg.Logger = zerologadapter.NewLogger(*dbctx.Logger)
    cfg.LogLevel = env.PgxlogLevel()
    dbctx.Db = stdlib.OpenDB(*cfg)

    return dbctx, nil
}

func (dbctx *PgxContext) GetDb() *sql.DB {
    if dbctx == nil {
        return nil
    }
    return dbctx.Db
}

func (dbctx *PgxContext) GetLogger() *zerolog.Logger {
    if dbctx == nil {
        lggr := zerolog.Nop()
        return &lggr
    }
    return dbctx.Logger
}

func (dbctx *PgxContext) Acquire() (*pgx.Conn, error) {
    if db := dbctx.GetDb(); db != nil {
        conn, err := stdlib.AcquireConn(db)
        return conn, errs.Wrap(err)
    }
    return nil, errs.New("*sql.DB instance is nil.")
}

func (dbctx *PgxContext) Close() error {
    if db := dbctx.GetDb(); db != nil {
        return errs.Wrap(db.Close())
    }
    return nil
}

dbconn.PgxContext というコンテキストを作って,これを返している。これは logger を *sql.DB インスタンスの外側で使えるようにするため。また Acquire() メソッドを使って *pgx.Conn インスタンスを取り出せるようにしてみた。

orm サブパッケージ

最後に GORM のインスタンスを作るパッケージ。

orm/gorm.go
package orm

import (
    "sample/dbconn"
    "sample/env"

    "github.com/rs/zerolog"
    "github.com/goark/errs"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
    "gorm.io/gorm/logger"
)

type GormContext struct {
    Db     *gorm.DB
    Logger *zerolog.Logger
}

func NewGORM() (*GormContext, error) {
    pgxCtx, err := dbconn.NewPgx()
    if err != nil {
        return nil, errs.Wrap(err)
    }
    gormCtx := &GormContext{
        Logger: pgxCtx.GetLogger(),
    }
    loggr := logger.Discard
    if env.LogLevel() == env.LevelDebug {
        loggr = logger.Default.LogMode(logger.Info)
    }
    gormCtx.Db, err = gorm.Open(postgres.New(postgres.Config{
        Conn: pgxCtx.GetDb(),
    }), &gorm.Config{
        Logger: loggr,
    })
    if err != nil {
        pgxCtx.GetLogger().Error().Interface("error", errs.Wrap(err)).Msg("error in gorm.Open() method")
        pgxCtx.Close()
        return nil, errs.Wrap(err)
    }
    return gormCtx, nil
}

func (gormCtx *GormContext) GetDb() *gorm.DB {
    if gormCtx == nil {
        return nil
    }
    return gormCtx.Db
}

func (gormCtx *GormContext) GetLogger() *zerolog.Logger {
    if gormCtx == nil {
        lggr := zerolog.Nop()
        return &lggr
    }
    return gormCtx.Logger
}

func (gormCtx *GormContext) Close() error {
    if db := gormCtx.GetDb(); db != nil {
        if sqlDb, err := db.DB(); err == nil {
            sqlDb.Close()
        }
    }
    return nil
}

これも同じく logger を含めた gorm.GormContext を返している。あと env ファイルで LOGLEVELdebug と指定されている場合は GORM の logger も有効とするようにした。

起動サンプル

以上のサブパッケージ群を使ってサンプルコードを書き直すとこうなる。

sampele2.go
//go:build run
// +build run

package main

import (
    "fmt"
    "os"
    "sample/orm"

    "github.com/goark/errs"
    "github.com/goark/gocli/exitcode"
)

func Run() exitcode.ExitCode {
    // create gorm.DB instance for PostgreSQL service
    gormCtx, err := orm.NewGORM()
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        return exitcode.Abnormal
    }
    defer gormCtx.Close()

    // query
    var results []map[string]interface{}
    tx := gormCtx.GetDb().Table("tablename").Find(&results) // "tablename" is not exist
    if tx.Error != nil {
        gormCtx.GetLogger().Error().Interface("error", errs.Wrap(tx.Error)).Send()
        return exitcode.Abnormal
    }

    return exitcode.Normal
}

func main() {
    Run().Exit()
}

うんうん。だいぶスッキリしたねぇ。これの実行結果は以下の通り。

$ go run sample2.go 
0:00AM INF Dialing PostgreSQL server host=hostname module=pgx
0:00AM INF Exec args=[] commandTag=null module=pgx pid=11556 sql=;
0:00AM ERR Query args=[] err="ERROR: relation \"tablename\" does not exist (SQLSTATE 42P01)" module=pgx pid=11556 sql="SELECT * FROM \"tablename\""
0:00AM ERR  error={"Context":{"function":"main.Run"},"Err":{"Msg":"ERROR: relation \"tablename\" does not exist (SQLSTATE 42P01)","Type":"*pgconn.PgError"},"Type":"*errs.Error"}
0:00AM INF closed connection module=pgx pid=11556

ん,問題なく SELECT 文でエラーになるね(笑)

まぁ,パッケージ間の関係が密すぎてテストが書きにくい(つか書けんな,これ)のはご容赦。もう少し弄りたい気持ちはあるが,今回は調査だし,この辺で勘弁してやろう(笑)