context helpers, rework logging to support ongoing inheritance

Leaves module logger property as purely an override, pulling logger
information directly from the lifecycle instead.
This commit is contained in:
Elijah Duffy
2025-06-04 13:36:40 -07:00
parent 6f9e3731ac
commit c3c8a2cafc
5 changed files with 188 additions and 115 deletions

8
go.mod
View File

@@ -1,3 +1,11 @@
module gitea.auvem.com/go-toolkit/app
go 1.24.0
require github.com/stretchr/testify v1.10.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

10
go.sum Normal file
View File

@@ -0,0 +1,10 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,6 +1,7 @@
package app
import (
"context"
"errors"
"fmt"
"log/slog"
@@ -8,19 +9,23 @@ import (
"strings"
)
type contextKey string
const lifecycleContextKey contextKey = "lifecycle"
// LifecycleOpts contains user-exposed options when defining a lifecycle.
type LifecycleOpts struct {
// DisableAutoload disables dependency autoloading (enabled by default).
DisableAutoload bool
// Logger is the logger for the lifecycle. If not set, it will be initialized
// by the lifecycle using the default logger (slog.Default).
Logger *slog.Logger
}
// Lifecycle represents the core application structure. Lifecycle manages
// resources providing an orchestrator for setup and teardown of modules.
type Lifecycle struct {
// Logger is the logger for the lifecycle. If not set, it will be initialized
// by the lifecycle itself using the default logger.
logger *slog.Logger
modules []*Module
opts LifecycleOpts
@@ -33,46 +38,59 @@ type Lifecycle struct {
// NewLifecycle creates a new Lifecycle instance with a default logger and the
// given modules. It panics if any module has a duplicate name.
func NewLifecycle(modules ...*Module) *Lifecycle {
return NewLifecycleL(nil, modules...)
}
// NewLifecycleL creates a new Lifecycle instance with a specific logger and the
// given modules. See NewLifecycle for more details.
func NewLifecycleL(defaultLogger *slog.Logger, modules ...*Module) *Lifecycle {
// Use the provided logger or initialize a default one
if defaultLogger == nil {
defaultLogger = slog.Default()
}
// Ensure modules are unique
unique := make(map[string]bool)
for _, mod := range modules {
if _, exists := unique[mod.name]; exists {
panic(fmt.Sprintf("duplicate module name: %s", mod.name))
panic(fmt.Sprintf("duplicate module: %s", mod))
}
unique[mod.name] = true
}
return &Lifecycle{
logger: defaultLogger,
modules: modules,
setupTracker: make(map[string]int),
teardownTracker: make(map[string]int),
}
}
// LifecycleToContext adds the Lifecycle to the context.
func LifecycleToContext(ctx context.Context, lifecycle *Lifecycle) context.Context {
if lifecycle == nil {
return ctx
}
return context.WithValue(ctx, lifecycleContextKey, lifecycle)
}
// LifecycleFromContext retrieves the Lifecycle from the context. Returns nil if not found.
func LifecycleFromContext(ctx context.Context) *Lifecycle {
if lifecycle, ok := ctx.Value(lifecycleContextKey).(*Lifecycle); ok {
return lifecycle
}
return nil
}
// WithOpts sets the options for the lifecycle.
func (app *Lifecycle) WithOpts(opts LifecycleOpts) *Lifecycle {
app.opts = opts
return app
}
// WithLogger sets the logger for the lifecycle. Panics if the logger is nil.
func (app *Lifecycle) WithLogger(logger *slog.Logger) *Lifecycle {
if logger == nil {
panic("logger cannot be nil")
}
app.opts.Logger = logger
return app
}
// Logger returns the logger for the lifecycle.
func (app *Lifecycle) Logger() *slog.Logger {
if app.logger == nil {
panic("lifecycle logger is not initialized")
if app.opts.Logger == nil {
app.opts.Logger = slog.Default()
}
return app.logger
return app.opts.Logger
}
// Setup initializes all modules in the order they were defined and checks
@@ -81,12 +99,12 @@ func (app *Lifecycle) Logger() *slog.Logger {
// of the application lifecycle to ensure all resources are cleaned up properly.
func (app *Lifecycle) Setup() error {
for _, mod := range app.modules {
if err := app.setupSingle(mod, app.logger); err != nil {
if err := app.setupSingle(nil, mod); err != nil {
return err
}
}
app.logger.Info("Lifecycle modules initialized, backend setup complete",
app.Logger().Info("Lifecycle modules initialized, backend setup complete",
"modules", mapToString(app.setupTracker))
return nil
@@ -105,11 +123,11 @@ func (app *Lifecycle) Teardown() error {
}
if err != nil {
app.logger.Error("Error tearing down modules", "failures", app.setupCount-app.teardownCount, "error", err)
app.Logger().Error("Error tearing down modules", "failures", app.setupCount-app.teardownCount, "error", err)
return err
}
app.logger.Info("All modules torn down, backend teardown complete",
app.Logger().Info("All modules torn down, backend teardown complete",
"modules", mapToString(app.teardownTracker))
return nil
@@ -120,13 +138,13 @@ func (app *Lifecycle) Teardown() error {
// but may still be conditionally necessary. Any modules that are already
// set up are ignored.
func (app *Lifecycle) Require(modules ...*Module) error {
return app.RequireL(app.logger, modules...)
return app.require(nil, false, modules...)
}
// RequireUnique is the same as Require, but it returns an error if any requested
// module is already set up--rather than ignoring it.
func (app *Lifecycle) RequireUnique(modules ...*Module) error {
return app.RequireUniqueL(app.logger, modules...)
return app.require(nil, true, modules...)
}
// RequireL adds module(s) to the lifecycle with a specific logger and
@@ -135,21 +153,39 @@ func (app *Lifecycle) RequireUnique(modules ...*Module) error {
// default logger.
func (app *Lifecycle) RequireL(logger *slog.Logger, modules ...*Module) error {
if logger == nil {
logger = app.logger
return fmt.Errorf("logger cannot be nil")
}
return app.require(logger, false, modules...)
}
// RequireUniqueL is the same as RequireL, but it returns an error if any requested
// module is already set up--rather than ignoring it.
func (app *Lifecycle) RequireUniqueL(logger *slog.Logger, modules ...*Module) error {
if logger == nil {
return fmt.Errorf("logger cannot be nil")
}
return app.require(logger, true, modules...)
}
// require is a helper function that attempts to add module(s) to the lifecycle
// and immediately run any setup functions.
func (app *Lifecycle) require(logger *slog.Logger, unique bool, modules ...*Module) error {
if len(modules) == 0 {
return fmt.Errorf("no modules to require")
}
for _, mod := range modules {
for i, mod := range modules {
if mod == nil {
return fmt.Errorf("module is nil")
return fmt.Errorf("module %d is nil", i)
}
// Check if the module is already set up
// Check if the module has already been set up
if _, ok := app.setupTracker[mod.name]; ok {
app.logger.Warn("module already set up, ignoring", "module", mod.name)
if unique {
return fmt.Errorf("module %s is already set up, cannot require it again", mod)
}
app.Logger().Warn("module already set up, ignoring", "module", mod)
mod.loaded = true
mod.logger = logger
continue
@@ -159,57 +195,40 @@ func (app *Lifecycle) RequireL(logger *slog.Logger, modules ...*Module) error {
app.modules = append(app.modules, mod)
// Run the setup function for the module
if err := app.setupSingle(mod, logger); err != nil {
return fmt.Errorf("error setting up required module '%s': %w", mod.name, err)
if err := app.setupSingle(logger, mod); err != nil {
return fmt.Errorf("error setting up required module %s: %w", mod, err)
}
}
app.logger.Info("New modules initialized", "all", mapToString(app.setupTracker))
app.Logger().Info("New modules initialized", "all", mapToString(app.setupTracker))
return nil
}
// RequireUniqueL is the same as RequireL, but it returns an error if any requested
// module is already set up--rather than ignoring it.
func (app *Lifecycle) RequireUniqueL(logger *slog.Logger, modules ...*Module) error {
// Check if any module is already set up
for _, mod := range modules {
if mod == nil {
return fmt.Errorf("module is nil")
}
if _, ok := app.setupTracker[mod.name]; ok {
return fmt.Errorf("module '%s' is already set up, cannot require it again", mod.name)
}
}
return app.RequireL(logger, modules...)
}
// setupSingle is a helper function to set up a single module. Returns an error
// if the module cannot be set up or if dependencies are not satisfied.
func (app *Lifecycle) setupSingle(mod *Module, logger *slog.Logger) error {
func (app *Lifecycle) setupSingle(logger *slog.Logger, mod *Module) error {
if mod == nil {
return fmt.Errorf("module is nil")
}
// Set the logger for the module
// Set the parent lifecycle and logger override
mod.lifecycle = app
mod.logger = logger
// Check if all dependencies are satisfied
for _, dep := range mod.depends {
if _, ok := app.setupTracker[dep]; !ok {
if app.opts.DisableAutoload {
return fmt.Errorf("dependency '%s' not satisfied for '%s'", dep, mod.name)
return fmt.Errorf("dependency %s not satisfied for '%s'", dep, mod)
} else {
// Attempt to set up the dependency
depmod, err := app.getModuleByName(dep)
if err != nil {
return fmt.Errorf("error getting dependency '%s' for '%s': %w", dep, mod.name, err)
return fmt.Errorf("error getting dependency '%s' for %s: %w", dep, mod, err)
}
if err := app.setupSingle(depmod, logger); err != nil {
return fmt.Errorf("error setting up dependency '%s' for '%s': %w", dep, mod.name, err)
if err := app.setupSingle(logger, depmod); err != nil {
return fmt.Errorf("error setting up dependency %s for %s: %w", depmod, mod, err)
}
}
}
@@ -218,7 +237,7 @@ func (app *Lifecycle) setupSingle(mod *Module, logger *slog.Logger) error {
if mod.setup != nil {
// Run the setup function for the module
if err := mod.setup(mod); err != nil {
return fmt.Errorf("error initializing '%s': %w", mod.name, err)
return fmt.Errorf("error initializing %s: %w", mod, err)
}
}
@@ -238,13 +257,13 @@ func (app *Lifecycle) teardownSingle(mod *Module) error {
// Check if the module was set up
if _, ok := app.setupTracker[mod.name]; !ok {
return fmt.Errorf("module '%s' is not set up, cannot tear down", mod.name)
return fmt.Errorf("module %s is not set up, cannot tear down", mod)
}
// Run the teardown function for the module
if mod.teardown != nil {
if err := mod.teardown(mod); err != nil {
return fmt.Errorf("error tearing down '%s': %w", mod.name, err)
return fmt.Errorf("error tearing down %s: %w", mod, err)
}
}

View File

@@ -3,6 +3,7 @@ package app
import (
"errors"
"log/slog"
"os"
"testing"
"github.com/stretchr/testify/assert"
@@ -11,20 +12,20 @@ import (
func TestNewLifecycle(t *testing.T) {
assert := assert.New(t)
// Create a blank Lifecycle instance
lc := NewLifecycle()
assert.NotNil(lc, "expected Lifecycle instance to be created")
assert.Equal(slog.Default(), lc.logger, "expected default logger to be set")
}
func TestNewLifecycleL(t *testing.T) {
assert := assert.New(t)
assert.Equal(slog.Default(), lc.Logger(), "expected default logger to be available")
// Create a blank Lifecycle instance with a custom logger
logger := slog.Default()
lc := NewLifecycleL(logger)
handler := slog.NewTextHandler(os.Stderr, nil)
logger := slog.New(handler)
lc = NewLifecycle().WithOpts(LifecycleOpts{
Logger: logger,
})
assert.NotNil(lc)
assert.Equal(logger, lc.logger, "expected logger to be set")
assert.Equal(logger, lc.Logger(), "expected logger to match")
assert.Len(lc.modules, 0, "expected modules to be empty")
assert.Len(lc.setupTracker, 0, "expected setup tracker to be empty")
assert.Len(lc.teardownTracker, 0, "expected teardown tracker to be empty")
@@ -33,15 +34,15 @@ func TestNewLifecycleL(t *testing.T) {
mod1 := NewModule("module1", ModuleOpts{})
mod2 := NewModule("module2", ModuleOpts{})
lcWithModules := NewLifecycleL(logger, mod1, mod2)
lcWithModules := NewLifecycle(mod1, mod2)
assert.NotNil(lcWithModules, "expected Lifecycle with modules to be created")
assert.Contains(lcWithModules.modules, mod1, "expected module1 to be included")
assert.Contains(lcWithModules.modules, mod2, "expected module2 to be included")
assert.Len(lcWithModules.modules, 2, "expected two modules to be present")
// Test for duplicate module names
assert.PanicsWithValue("duplicate module name: module1", func() {
NewLifecycleL(logger, mod1, mod1)
assert.PanicsWithValue("duplicate module: [module1]", func() {
NewLifecycle(mod1, mod1)
}, "expected panic on duplicate module names")
}
@@ -57,17 +58,25 @@ func TestLifecycle_WithOpts(t *testing.T) {
assert.Equal(opts, lc.opts, "expected Lifecycle to have the provided options")
}
func TestLifecycle_WithLogger(t *testing.T) {
assert := assert.New(t)
lc := NewLifecycle()
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
lc.WithLogger(logger)
assert.Equal(logger, lc.Logger(), "expected Lifecycle to have the provided logger")
}
func TestLifecycle_Logger(t *testing.T) {
assert := assert.New(t)
lc := NewLifecycle()
assert.Equal(slog.Default(), lc.Logger(), "expected Logger to return a logger")
// Test panic when logger is not initialized
lc.logger = nil
assert.PanicsWithValue("lifecycle logger is not initialized", func() {
lc.Logger()
}, "expected Logger to panic when logger is not set")
// Test that custom logger is used
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
lc.opts.Logger = logger
assert.Equal(logger, lc.Logger())
}
func TestLifecycle_Setup(t *testing.T) {
@@ -190,6 +199,7 @@ func TestLifecycle_Teardown(t *testing.T) {
// Fake setup for all modules
for _, mod := range tc.modules {
mod.loaded = true // Mark as loaded
lc.setupTracker[mod.name] = 0 // Mark as set up
}
@@ -200,7 +210,8 @@ func TestLifecycle_Teardown(t *testing.T) {
for _, mod := range tc.modules {
if mod.teardown != nil {
_, ok := lc.teardownTracker[mod.name]
assert.True(ok, "expected module to be marked as torn down")
assert.Truef(ok, "expected module %s to be marked as torn down", mod)
assert.Falsef(mod.loaded, "expected module %s to be unloaded after teardown", mod)
}
}
} else {
@@ -218,22 +229,33 @@ func TestLifecycle_Require(t *testing.T) {
mod := NewModule("module1", ModuleOpts{
Setup: func(m *Module) error { return nil },
})
lc.Require(mod)
assert.Equal(t, lc.logger, mod.logger, "expected module logger to match lifecycle logger")
err := lc.Require(mod)
assert.NoError(t, err, "expected Require to succeed")
assert.Contains(t, lc.setupTracker, mod.name, "expected module to be marked as set up")
assert.Nil(t, mod.logger, "expected module logger to be nil")
assert.Equal(t, lc.Logger(), mod.Logger(), "expected module logger to match lifecycle logger")
}
func TestLifecycle_RequireUnique(t *testing.T) {
assert := assert.New(t)
lc := NewLifecycle()
mod := NewModule("module1", ModuleOpts{})
err := lc.RequireUnique(mod)
assert.NoError(t, err, "expected RequireL to succeed")
assert.Equal(t, lc.logger, mod.logger, "expected module logger to match lifecycle logger")
assert.NoError(err, "expected RequireL to succeed")
assert.Nil(mod.logger, "expected module logger to be nil")
// Requiring the same module again should fail
err = lc.RequireUnique(mod)
assert.Error(err, "expected RequireUnique to fail on duplicate module")
}
func TestLifecycle_RequireL(t *testing.T) {
// Create a logger for the test cases
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
cases := []struct {
name string
logger *slog.Logger
modules []*Module
existingModules []*Module
expectedErr string
@@ -245,7 +267,7 @@ func TestLifecycle_RequireL(t *testing.T) {
{
name: "nil module",
modules: []*Module{nil},
expectedErr: "module is nil",
expectedErr: "is nil",
},
{
name: "basic module",
@@ -309,21 +331,16 @@ func TestLifecycle_RequireL(t *testing.T) {
lc := NewLifecycle(tc.existingModules...)
l := tc.logger
if l == nil {
l = lc.logger
}
err := lc.RequireL(tc.logger, tc.modules...)
err := lc.RequireL(logger, tc.modules...)
if tc.expectedErr == "" {
assert.NoError(err, "expected RequireL to succeed")
for _, mod := range tc.modules {
for i, mod := range tc.modules {
if mod != nil {
_, ok := lc.setupTracker[mod.name]
assert.True(ok, "expected module to be marked as set up")
assert.True(mod.loaded, "expected module to be loaded")
assert.Equal(l, mod.logger, "expected module logger to match")
assert.Truef(ok, "expected module %d %s to be marked as set up", i, mod)
assert.Truef(mod.loaded, "expected module %d %s to be loaded", i, mod)
assert.Equalf(logger, mod.logger, "expected module %d %s logger to match", i, mod)
}
}
} else {
@@ -335,6 +352,9 @@ func TestLifecycle_RequireL(t *testing.T) {
}
func TestLifecycle_RequireUniqueL(t *testing.T) {
// Create a logger for the test cases
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
cases := []struct {
name string
modules []*Module
@@ -354,12 +374,12 @@ func TestLifecycle_RequireUniqueL(t *testing.T) {
NewModule("module2", ModuleOpts{}),
},
existingModules: []string{"module1"},
expectedErr: "module 'module1' is already set up, cannot require it again",
expectedErr: "module [module1] is already set up, cannot require it again",
},
{
name: "nil module",
modules: []*Module{nil},
expectedErr: "module is nil",
expectedErr: "is nil",
},
}
@@ -373,7 +393,7 @@ func TestLifecycle_RequireUniqueL(t *testing.T) {
lc.setupTracker[modName] = 0 // Mark as set up
}
err := lc.RequireUniqueL(lc.logger, tc.modules...)
err := lc.RequireUniqueL(logger, tc.modules...)
if tc.expectedErr == "" {
assert.NoError(err, "expected RequireUniqueL to succeed")
} else {
@@ -385,6 +405,9 @@ func TestLifecycle_RequireUniqueL(t *testing.T) {
}
func TestLifecycle_setupSingle(t *testing.T) {
// Create a logger for the test cases
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
cases := []struct {
name string
lc *Lifecycle
@@ -447,7 +470,7 @@ func TestLifecycle_setupSingle(t *testing.T) {
Setup: func(m *Module) error { return errors.New("dependency setup failed") },
}),
},
expectedErr: "error initializing 'errorModule'",
expectedErr: "dependency setup failed",
},
{
name: "dependency without autoload",
@@ -472,7 +495,7 @@ func TestLifecycle_setupSingle(t *testing.T) {
l := tc.logger
if l == nil {
l = slog.Default()
l = logger
}
lc := tc.lc
@@ -480,20 +503,19 @@ func TestLifecycle_setupSingle(t *testing.T) {
lc = NewLifecycle(tc.modules...)
}
err := lc.setupSingle(tc.targetModule, l)
if tc.targetModule != nil {
assert.Equal(l, tc.targetModule.logger, "expected module logger to match")
}
err := lc.setupSingle(l, tc.targetModule)
if tc.expectedErr == "" {
assert.NoError(err, "expected no error from setupSingle")
} else {
assert.Contains(err.Error(), tc.expectedErr, "expected error message to match")
}
if tc.targetModule != nil {
assert.Equal(l, tc.targetModule.logger, "expected module logger to match")
}
})
}
}
func TestLifecycle_teardownSingle(t *testing.T) {

View File

@@ -13,9 +13,17 @@ var ErrModuleNotFound = errors.New("module not found")
// moduleFn is a function type for module setup and teardown functions.
type moduleFn func(*Module) error
// GenericModule is an interface that allows modules to be extended with custom
// functionality, as the module can return a pointer to the underlying Module.
type GenericModule interface {
// Module returns the underlying Module instance.
Module() *Module
}
// Module represents a sub-system of the application, with its setup and
// teardown functions and dependencies.
type Module struct {
lifecycle *Lifecycle // lifecycle is the parent lifecycle this module belongs to
logger *slog.Logger
name string
setup moduleFn
@@ -45,12 +53,13 @@ func NewModule(name string, opts ModuleOpts) *Module {
}
}
// Logger returns the logger for the module.
// Logger returns the logger for the module. Uses the lifecycle's logger unless
// a specific logger has been set during module load.
func (s *Module) Logger() *slog.Logger {
if s.logger == nil {
panic(fmt.Sprintf("module %s used before logger was initialized", s.name))
}
if s.logger != nil {
return s.logger
}
return s.lifecycle.Logger()
}
// Name returns the name of the module.
@@ -58,6 +67,11 @@ func (s *Module) Name() string {
return s.name
}
// String returns the name of the module in square brackets, e.g. "[module-name]".
func (s *Module) String() string {
return fmt.Sprintf("[%s]", s.name)
}
// Loaded returns whether the module has been set up.
func (s *Module) Loaded() bool {
return s.loaded