Go Development
This article won't compare all the loggers available out there nor will we tackle specific cases of what should or shouldn't be logged. Instead, we will focus entirely on how to use a logger of your choosing in your application.
In all examples we will be using the builtin log
package for simplicity and ease of trying it out (e.g. in Go's playground).
package main
import (
"log"
"os"
)
var logger = log.New(os.Stdout, "", 5)
func main() {
logger.Println("Hello, playground")
// or
log.Println("Hello, playground")
}
Available to every function[1]
It's a common approach in many libraries, often given as an example[2]
Tight coupling[3]
Testing is difficult[4]
Hides state/behavior[5]
It is sometimes discouraged by the creators of logging libraries[6]
To address concerns with global logger, in all other approaches we'll be using a Logger
interface:
type Logger interface {
Debug(args ...interface{})
Debugf(format string, args ...interface{})
}
A Logger interface contains just the Debug-related methods for simplicity, but you can add more methods if you'd like.
Sample implementation using built-in Go's logger:
type BuiltinLogger struct {
logger *log.Logger
}
func NewBuiltinLogger() *BuiltinLogger {
return &BuiltinLogger{logger: log.New(os.Stdout, "", 5)}
}
func (l *BuiltinLogger) Debug(args ...interface{}) {
l.logger.Println(args...)
}
func (l *BuiltinLogger) Debugf(format string, args ...interface{}) {
l.logger.Printf(format, args...)
}
var logger Logger = NewBuiltinLogger()
func main() {
logger.Debugf("Hello with %s", "formatting")
}
Can be easily replaced with another implementation
Available to every function [1]
Much like the previous logigng approach, this loggeris also still hard to test. It isn't possible to simply inject anotherimplementation just fortests
It needs to have some kind of a default implementation initialized so it doesn't break tests
func DoStuff(ctx context.Context) {
log := ctx.Value("logger").(Logger)
log.Debugf("Hello with %s", "formatting")
}
func main() {
ctx := context.WithValue(context.Background(), "logger", NewBuiltinLogger())
DoStuff(ctx)
}
There's no need to pass logger everywhere. This is because you will be re-using the context, which is often passed as a first parameter to all functions
This log approach can be easily replaced with other loggers
Context doesn't have any schema, so there's no guarantee that the key is in the context [3]
Logger becomes an implicit dependency
[...] passing loggers inside context.Context would be the worst solution to the problem of decoupling loggers from implementations. Weʼd have gone from an explicit compile time dependency to an implicit run time dependency, one that could not be enforced by the compiler.[3]
type Stuff struct {
logger Logger
}
func NewStuff(logger Logger) *Stuff {
return &Stuff{logger: logger}
}
func (s *Stuff) DoStuff() {
s.logger.Debug("Hello playground")
s.logger.Debugf("Hello with %s", "formatting")
}
func main() {
s := NewStuff(NewBuiltinLogger())
s.DoStuff()
}
Dependencies should be explicit
It can be easily replaced with another implementation
The only one which allows to injection of a mock for testing purposes
Ad. 1.
Make dependencies explicit! Loggers are dependencies [...] [4]
[...] logging, like instrumentation, is often crucial to the operation of a service. And hiding dependencies in the global scope can and does come back to bite us, whetheritʼs something as seemingly benign as a logger, or perhaps another, more important, domain-specific component that we havenʼt bothered to parameterize. Save yourself the future pain by being strict: make all your dependencies explicit.[4]
[...] itʼs the only way to achieve decoupled design.[3]
Verbose[3] - we need to pass it to any struct (or function) we're using
At this stage, you're probably wondering: which logging approach should I choose?
We believe it depends on the codebase you're dealing with. For a brand new project injecting an explicit dependency is a worthwhile option. However, if you're dealing with legacy code, changing it overnight is hard. For that reason, in this case we would advise opting for a global logger used as an interface.
No matter which Golang logging approaches you choose, always pass it as an interface, and not as a concrete type. This will guarantee you the flexibility to change components of your code whenever you need, and also helps to avoid spreading external dependencies everywhere.