🐕

[Go]ginでzapロガーを使って構造化ログを吐き出す

2024/05/07に公開

目次

  1. はじめに
  2. ginで素のzapロガーを使う
  3. gin-contrib/zapを使う
  4. まとめ

はじめに

ginでzapロガーを使って構造化ログを吐き出す方法についてまとめます

GitHubのリポジトリはこちら

ブランチ構成

  • main: zapロガーをmiddlewareとして自分で実装
  • contrib: gin-contribを使って実装

共通部分の実装

Dockerfileはマルチステージビルドを使います

devステージでビルド開発を行い、productionステージでビルドしたバイナリを実行します

devステージでは開発ツールやswaggerのインストールを行い、快適に開発を行いつつ、productionステージで極小のバイナリで実行します

実行後のバイナリサイズは数十MB程度になります

※マルチステージビルドを使わない場合は10倍以上のバイナリサイズです

また環境変数で環境変数違いによる挙動の変更を行います

私たちのような製造業の現場では、同じアプリケーションなんだけど違うオンプレミスサーバーにデプロイしたい!という場合にこのように環境変数で挙動を変えることが多いです

例えばクラウド上のDBのキーに環境変数としてSERVER_ID=xxxxxxxなどのようにエッジデバイスにIDを割り振り、そのIDによって対象設備やアプリケーションのログ出力を管理できます。

いつかk3sで実装しているクラスター構成なども記事にしたいと思います

FROM golang:1.22-alpine as dev

WORKDIR /app

COPY ./ ./
RUN go mod download
RUN go install github.com/go-delve/delve/cmd/dlv@latest
RUN go install golang.org/x/tools/gopls@latest
RUN go install github.com/swaggo/swag/cmd/swag@latest


ARG ENV_KEY
ENV ENV_KEY=${ENV_KEY}

RUN go build -o /httpserver

FROM alpine:latest as production
ARG ENV_KEY
ENV ENV_KEY=${ENV_KEY}
COPY --from=dev /httpserver ./httpserver
WORKDIR /app
ENV PORT=8080
ENTRYPOINT [ "/httpserver" ]

docker-compose.ymlは主に開発時のビルドを行うための設定を記述します

そのためtarget: devとして、devステージまでビルドを行うようにしています

本番環境はクラウドへデプロイするなりしてください

# docker-compose.yml
services:
  httpserver:
    image: httpserver:latest
    container_name: "httpserver"
    ports:
      - 8080:8080
    build:
      context: ./
      dockerfile: Dockerfile
      # docker-compose file for dev
      target: dev
    volumes:
      - ./:/app
    tty: true
    restart: always
    environment:
      - ENV_KEY=ENV_VALUE

ディレクトリ構成の違い

ginで素のzapロガーを使う

.
├── Dockerfile
├── docker-compose.yml
├── go.mod
├── go.sum
├── handlers
│   ├── sample-handlers.go
│   └── sample-handlers_test.go
├── main.go
└── middleware
    └── logger.go

gin-contrib/zapを使う

.
├── Dockerfile
├── docker-compose.yml
├── go.mod
├── go.sum
├── handlers
│   ├── sample-handlers.go
│   └── sample-handlers_test.go
└── main.go

ginで素のzapロガーを使う

zapロガー(https://github.com/uber-go/zap)をそのまま使います

requestのbodyやheader、responseのsizeなどをログに出力します

// middleware/logger.go
package middleware

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/rs/xid"
	zap "go.uber.org/zap"
)

func Logger(l *zap.Logger) gin.HandlerFunc {
	return func(c *gin.Context) {
		start := time.Now()
		c.Next()

		bodyBytes, _ := io.ReadAll(c.Request.Body)
		c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))

		heaserBytes, err := json.Marshal(c.Request.Header)
		if err != nil {
			fmt.Println("failed to marshal header")
		}
		headerStr := string(heaserBytes)

		// log here
		l.Info("Request",
			zap.String("uuid", xid.New().String()),
			zap.Int("status", c.Writer.Status()),
			zap.Int64("content_length", c.Request.ContentLength),
			zap.String("method", c.Request.Method),
			zap.String("path", c.Request.URL.Path),
			zap.String("query", c.Request.URL.RawQuery),
			zap.String("request_body", string(bodyBytes)),
			zap.String("ip", c.ClientIP()),
			zap.String("user_agent", c.Request.UserAgent()),
			zap.String("errors", c.Errors.ByType(gin.ErrorTypePrivate).String()),
			zap.Duration("elapsed", time.Since(start)),
			zap.String("header", headerStr),
			zap.Int("response_size(bytes)", c.Writer.Size()),
		)
	}

}

main.goではloggerをmiddlewareとして登録します
おまけにswaggerも追加しています

// main.go
package main

import (
	"httpserver/docs"
	handlers "httpserver/handlers"
	"httpserver/middleware"
	"os"
	"time"

	"github.com/gin-contrib/cors"
	"github.com/gin-gonic/gin"
	swaggerFiles "github.com/swaggo/files"
	ginSwagger "github.com/swaggo/gin-swagger"
	"go.uber.org/zap"
)

// @title           Swagger Example API
// @version         1.0
// @description     This is a httpserver
// @termsOfService  http://swagger.io/terms/

// @license.name  Apache 2.0
// @license.url   http://www.apache.org/licenses/LICENSE-2.0.html

// @host      your-url.com
// @BasePath  /

// @securityDefinitions.basic  BasicAuth

// @externalDocs.description  OpenAPI
// @externalDocs.url          https://swagger.io/resources/open-api/
func main() {
	// get env value
	ENV_VALUE := os.Getenv("ENV_KEY")

	// set zap logger as default logger
	logger, err := zap.NewProduction()
	if err != nil {
		os.Exit(1)
	}

	gin.SetMode(gin.ReleaseMode)
	r := gin.Default()
	r.Use(cors.New(cors.Config{
		AllowMethods:    []string{"GET", "POST", "OPTIONS"},
		AllowAllOrigins: true,
		AllowWebSockets: true,
		MaxAge:          12 * time.Hour,
	}))
	r.Use(middleware.Logger(logger))
	docs.SwaggerInfo.Title = "API Docs"
	docs.SwaggerInfo.Description = "This is a http server."
	docs.SwaggerInfo.Version = "1.0"
	docs.SwaggerInfo.Schemes = []string{"http", "https"}

	// swagger
	// use ginSwagger middleware to serve the API docs
	r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))

	// GET /sample
	r.GET("/sample", func(c *gin.Context) {
		handlers.SampleHandler(c, ENV_VALUE)
	})
	r.Run(":8080")

}

出力ログ↓

{"level":"info","ts":1715009587.3875072,"caller":"middleware/logger.go:46","msg":"Request","uuid":"cosfgcqef13l8m6uemh0","status":200,"content_length":0,"method":"GET","path":"/sample","query":"key=ss","request_body":"","ip":"172.19.0.1","user_agent":"PostmanRuntime/7.37.3","errors":"","elapsed":0.002162771,"header":"{\"Accept\":[\"*/*\"],\"Accept-Encoding\":[\"gzip, deflate, br\"],\"Connection\":[\"keep-alive\"],\"Content-Type\":[\"application/json\"],\"Postman-Token\":[\"9b32708d-a87b-485f-a12a-7998cf0eca27\"],\"User-Agent\":[\"PostmanRuntime/7.37.3\"]}","response_size(bytes)":64}

gin-contrib/zapを使う

gin-contribのzapを使います
先ほど裸のzapを使った場合と比べて、コードが簡潔になります

// main.go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"httpserver/docs"
	handlers "httpserver/handlers"
	"io"
	"os"
	"time"

	"github.com/gin-contrib/cors"
	ginzap "github.com/gin-contrib/zap"
	"github.com/gin-gonic/gin"
	"github.com/rs/xid"
	swaggerFiles "github.com/swaggo/files"
	ginSwagger "github.com/swaggo/gin-swagger"
	"go.uber.org/zap"
)

// @title           Swagger Example API
// @version         1.0
// @description     This is a httpserver
// @termsOfService  http://swagger.io/terms/

// @license.name  Apache 2.0
// @license.url   http://www.apache.org/licenses/LICENSE-2.0.html

// @host      your-url.com
// @BasePath  /

// @securityDefinitions.basic  BasicAuth

// @externalDocs.description  OpenAPI
// @externalDocs.url          https://swagger.io/resources/open-api/
func main() {
	// get env value
	ENV_VALUE := os.Getenv("ENV_KEY")

	// set zap logger as default logger
	logger, err := zap.NewProduction()
	if err != nil {
		os.Exit(1)
	}

	gin.SetMode(gin.ReleaseMode)
	r := gin.Default()
	r.Use(cors.New(cors.Config{
		AllowMethods:    []string{"GET", "POST", "OPTIONS"},
		AllowAllOrigins: true,
		AllowWebSockets: true,
		MaxAge:          12 * time.Hour,
	}))

	r.Use(ginzap.GinzapWithConfig(logger, &ginzap.Config{
		UTC:        true,
		TimeFormat: time.RFC3339,
		Context: ginzap.Fn(func(c *gin.Context) []zap.Field {
			start := time.Now()
			bodyBytes, _ := io.ReadAll(c.Request.Body)
			c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))

			// CloudLoggingに送信用のメッセージを定義
			heaserBytes, err := json.Marshal(c.Request.Header)
			if err != nil {
				fmt.Println("failed to marshal header")
			}
			headerStr := string(heaserBytes)
			return []zap.Field{
				zap.String("uuid", xid.New().String()),
				zap.Int("status", c.Writer.Status()),
				zap.Int64("content_length", c.Request.ContentLength),
				zap.String("method", c.Request.Method),
				zap.String("path", c.Request.URL.Path),
				zap.String("query", c.Request.URL.RawQuery),
				zap.String("request_body", string(bodyBytes)),
				zap.String("ip", c.ClientIP()),
				zap.String("user_agent", c.Request.UserAgent()),
				zap.String("errors", c.Errors.ByType(gin.ErrorTypePrivate).String()),
				zap.Duration("elapsed", time.Since(start)),
				zap.String("header", headerStr),
				zap.Int("response_size(bytes)", c.Writer.Size()),
			}
		}),
	}))

	docs.SwaggerInfo.Title = "API Docs"
	docs.SwaggerInfo.Description = "This is a http server."
	docs.SwaggerInfo.Version = "1.0"
	docs.SwaggerInfo.Schemes = []string{"http", "https"}

	// swagger
	// use ginSwagger middleware to serve the API docs
	r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))

	// GET /sample
	r.GET("/sample", func(c *gin.Context) {
		handlers.SampleHandler(c, ENV_VALUE)
	})
	r.Run(":8080")

}

出力ログ↓

{"level":"info","ts":1715009826.8321772,"caller":"zap@v1.1.3/zap.go:117","msg":"","status":200,"method":"GET","path":"/sample","query":"key=ss","ip":"172.19.0.1","user-agent":"PostmanRuntime/7.37.3","latency":0.000193689,"time":"2024-05-06T15:37:06Z","uuid":"cosfi8h6e1vethr1n2rg","status":200,"content_length":0,"method":"GET","path":"/sample","query":"key=ss","request_body":"","ip":"172.19.0.1","user_agent":"PostmanRuntime/7.37.3","errors":"","elapsed":0.000021581,"header":"{\"Accept\":[\"*/*\"],\"Accept-Encoding\":[\"gzip, deflate, br\"],\"Connection\":[\"keep-alive\"],\"Content-Type\":[\"application/json\"],\"Postman-Token\":[\"a34f514a-031e-4384-b353-ae7ba24e2b8a\"],\"User-Agent\":[\"PostmanRuntime/7.37.3\"]}","response_size(bytes)":64}

まとめ

ginでzapロガーを使って構造化ログを吐き出す方法についてまとめました

素のzapを使う場合はresponseWriterの構造体にWriteのヘルパーメソッドを追加して、responseWriterのWriteメソッドをオーバーライドする必要があり、コードが冗長になります

そこでgin-contrib/zapを使い、簡潔にわかりやすく実装することが出来ました

実は当初middlewareとして実装していたのですが、gin-contrib/zapがあることに気づき、

車輪の再発明をしていたことに気づきました。

車輪の再発明、ダメ絶対!

GitHubで編集を提案

Discussion