2
\$\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\$
2
  • 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, 2018 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, 2018 at 8:54

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Browse other questions tagged or ask your own question.