1
\$\begingroup\$

I want to implement a custom Writer that allows the native go logger to do log rotation, and I also wanted to be able to write tests for it.

The general idea was to create FileOpener File interfaces that can be mocked in the tests, but because I have no experience in go I wonder if this solution is too verbose or not elegant. Is there too much boilerplate?

Here is how it looks:

package logger

import (
    "fmt"
    "time"
    "sync"
    "os"
    "strings"
    "path/filepath"
    "io"
)

const timestampFormat = "2006-01-02 15:04:05.999999999"

type File interface {
    io.Closer
    Name() (string)
    Write(bytes []byte) (int, error)
    Stat() (os.FileInfo, error)
}

type FileOpener interface {
    OpenFile(name string, flag int, perm os.FileMode) (File, error)
}

type RotateWriter struct {
    fileOpener FileOpener
    lock       sync.Mutex
    filename   string
    fp         File
}

type defaultFileOpener struct{}

func (fo defaultFileOpener) OpenFile(name string, flag int, perm os.FileMode) (File, error) {
    return os.OpenFile(name, flag, perm)
}

func CreateRotateWriter(filename string, fileOpener FileOpener) (*RotateWriter) {
    if fileOpener == nil {
        return &RotateWriter{filename: filename, fileOpener: defaultFileOpener{}}
    }
    return &RotateWriter{filename: filename, fileOpener: fileOpener}
}

func (writer *RotateWriter) Write(bytes []byte) (int, error) {
    writer.lock.Lock()
    defer writer.lock.Unlock()

    extension := filepath.Ext(writer.filename)
    filename := strings.TrimSuffix(writer.filename, extension)

    // There is a specific constants that are used for formatting dates.
    // For example 2006 is the YYYYY, 01 is MM and 02 is DD
    // Check https://golang.org/src/time/format.go line 88 for reference
    fullFilename := filename + time.Now().UTC().Format("-2006-01-02") + extension

    // Open the file for the first time if we don't already did so
    if writer.fp == nil {
        fp, err := writer.fileOpener.OpenFile(fullFilename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0666)
        if err != nil {
            return 0, err
        }
        writer.fp = fp;
    }

    // We are now pointing to a different file. Close the previous one and open a new one
    if fullFilename != writer.fp.Name() {
        writer.fp.Close()
        fp, err := writer.fileOpener.OpenFile(fullFilename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0666)
        if err != nil {
            return 0, err
        }
        writer.fp = fp;
    }

    return writer.fp.Write([]byte("[" + time.Now().UTC().Format(timestampFormat) + "] " + string(bytes)))
}

The test:

package logger_test

import (
    "testing"
    "go-rmq/logger"
    "bytes"
    "regexp"
    "os"
)

type fileType struct {
    index    int
    name     string
    contents string
}

type defaultFileOpener struct {
}

var openFiles []*fileType;
func (fo defaultFileOpener) OpenFile(name string, flag int, perm os.FileMode) (logger.File, error) {
    file := &fileType{name: name, contents: "", index: len(openFiles) - 1}
    openFiles = append(openFiles, file)
    return file, nil
}

func (f *fileType) Close() error {
    openFiles = append(openFiles[:f.index], openFiles[:f.index+1]...)
    return nil
}
func (*fileType) Read(p []byte) (n int, err error)              { return 0, nil }
func (*fileType) ReadAt(p []byte, off int64) (n int, err error) { return 0, nil }
func (*fileType) Seek(offset int64, whence int) (int64, error)  { return 0, nil }
func (*fileType) Stat() (os.FileInfo, error)                    { return nil, nil }
func (f *fileType) Name() (string)                              { return f.name }

func (f *fileType) Write(bytes []byte) (int, error) {
    f.contents += string(bytes) + "\n"
    return len(bytes), nil
}

func TestRotateWriter_Write(t *testing.T) {
    writer := logger.CreateRotateWriter("name.log", &defaultFileOpener{})
    writer.Write([]byte("Message"))
    writer.Write([]byte("Another Message"))
    if len(openFiles) > 1 {
        t.Error("Only one log file can be open at all times")
    }

    match, _ := regexp.MatchString("name-\\d{4}-\\d{2}-\\d{2}.log", openFiles[0].Name())
    if match == false{
        t.Error("The log name must be in the format name-YYYY-MM-DD.log", openFiles[0].Name())
    }

    match, _ = regexp.MatchString("\\[\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{1,9}] Message\n" +
        "\\[\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{1,9}] Another Message", openFiles[0].contents)
    if match == false {
        t.Fatal("output must match the format [YYYY-MM-DD HH:II:SS.mmmmmmmmm] Message")
    }

}
\$\endgroup\$
  • 1
    \$\begingroup\$ IMO log rotation is absolutely not the job of a program/application. The OS provides a standard way of rotating log files that works the same across all programs and applications and it is what uses/administrators should expect to see/use. \$\endgroup\$ – Dave C Sep 3 '18 at 19:35
  • \$\begingroup\$ Totally agree, but unfortunately it was part of the requirements at the time of writing this question. \$\endgroup\$ – Loupax Sep 4 '18 at 8:54

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.