diff --git a/README.md b/README.md index af5879c..5aa61f5 100644 --- a/README.md +++ b/README.md @@ -8,23 +8,23 @@ ## Description -Package izidic defines a tiny dependency injection container for Go projects. +Package [izidic](https://github.com/fgm/izidic) defines a tiny dependency injection container for Go projects. That container can hold two different kinds of data: - parameters, which are mutable data without any dependency; -- services, which are functions providing a typed object providing a feature, +- services, which are functions returning a typed object providing a feature, and may depend on other services and parameters. The basic feature is that storing service definitions does not create instances, -allowing users to store definitions of services requiring other services +allowing users to store definitions of services requiring other services, before those are actually defined. Notice that parameters do not need to be primitive types. For instance, most applications are likely to store a `stdout` object with value `os.Stdout`. Unlike heavyweights like google/wire or uber/zap, it works as a single step, -explicit, process, without reflection or code generation, to keep everything in sight. +explicit process, without reflection or code generation, to keep everything in sight. ## Usage @@ -37,8 +37,8 @@ explicit, process, without reflection or code generation, to keep everything in | Store parameters in the DIC | `dic.Store("executable", os.Args[0])` | | Register services with the DIC | `dic.Register("logger", loggerService)` | | Freeze the container | `dic.Freeze()` | -| Read a parameter from the DIC | `dic.Param(name)` | -| Get a service instance from the DIC | `dic.Service(name)` | +| Read a parameter from the DIC | `p, err := dic.Param(name)` | +| Get a service instance from the DIC | `s, err := dic.Service(name)` | Freezing applies once all parameters and services are stored and registered, and enables concurrent access to the container. @@ -54,9 +54,9 @@ Parameters can be any value type. They can be stored in the container in any ord Services like `loggerService` in the previous example are instances ot the `Service` type, which is defined as: -`type Service func(*Container) (any, error)` +`type Service func(Container) (any, error)` -- Services can use any other service and parameters to return the instance they +- Services can reference any other service and parameters from the container, to return the instance they build. The only restriction is that cycles are not supported. - Like parameters, services can be registered in any order on the container, so feel free to order the registrations in alphabetical order for readability. @@ -68,22 +68,20 @@ which is defined as: ### Accessing the container -- General parameter access: `s, err := dic.Param("name")` +- Parameter access: `s, err := dic.Param("name")` - Check the error against `nil` - - Type-assert the parameter value: `name := s.(string)` - - The type assertion cannot fail if the error was `nil` -- Simplified parameter access: `name := dic.MustParam("name").(string)` -- General service access: `s, err := dic.Service("logger")` + - Type-assert the parameter value: `name, ok := s.(string)` + - Or use shortcut: `name := dic.MustParam("name").(string)` +- Service access: `s, err := dic.Service("logger")` - Check the error against `nil` - - Type-assert the service instance value: `logger := s.(*log.Logger)` - - The type assertion cannot fail if the error was `nil` -- Simplified service access: `logger := dic.MustService("logger").(*log.Logger)` + - Type-assert the service instance value: `logger, ok := s.(*log.Logger)` + - Or use shortcut: `logger := dic.MustService("logger").(*log.Logger)` ## Best practices ### Create a simpler developer experience -One limitation of having `Container.(Must)Param()` and `Container.(MustService)` +One limitation of having `Container.(Must)Param()` and `Container.(Must)Service()` return untyped results as `any` is the need to type-assert results on every access. To make this safer and better looking, a neat approach is to define an application @@ -100,40 +98,42 @@ import ( "github.com/fgm/izidic" ) -type container struct { - *izidic.Container +type Container struct { + izidic.Container } // Logger is a typed service accessor. -func (c *container) Logger() *log.Logger { +func (c *Container) Logger() *log.Logger { return c.MustService("logger").(*log.Logger) } // Name is a types parameter accessor. -func (c *container) Name() string { +func (c *Container) Name() string { return c.MustParam("name").(string) } // loggerService is an izidic.Service also containing a one-time initialization action. -func loggerService(dic *izidic.Container) (any, error) { +func loggerService(dic izidic.Container) (any, error) { w := dic.MustParam("writer").(io.Writer) - log.SetOutput(w) // Support dependency code not taking an injected logger. - logger := log.New(w, "", log.LstdFlags) + log.SetOutput(w) // Support dependency code not taking an injected logger. + logger := log.New(w, "", log.LstdFlags) return logger, nil } -func appService(dic *izidic.Container) (any, error) { - wdic := container{dic} // wrapped container with typed accessors - logger := dic.Logger() // typed service instance - name := dic.Name() // typed parameter value +func appService(dic izidic.Container) (any, error) { + wdic := Container{dic} // wrapped container with typed accessors + logger := wdic.Logger() // typed service instance + name := wdic.Name() // typed parameter value appFeature := makeAppFeature(name, logger) return appFeature, nil } -func resolve(w io.Writer, name string, args []string) izidic.Container { +func Resolve(w io.Writer, name string, args []string) izidic.Container { dic := izidic.New() + dic.Store("name", name) dic.Store("writer", w) dic.Register("logger", loggerService) + dic.Register("app", appService) // others... dic.Freeze() return dic @@ -141,13 +141,20 @@ func resolve(w io.Writer, name string, args []string) izidic.Container { ``` These accessors will be useful when defining services, as in `appService` above, -or in the boot sequence, which typically neeeds at least a `logger` and one or +or in the boot sequence, which typically needs at least a `logger` and one or more application-domain service instances. +### Create the container in a `Resolve` function + +The cleanest way to initialize a container is to have the +project contain an function, conventionally called `Resolve`, which takes all globals used in the project, +and returns an instance of the custom container type defined above, as in [examples/di/di.go](examples/di/di.go). + + ### Do not pass the container -Passing the container, although it works, defines the "service locator" anti-pattern. +Passing the container to application code, although it works, defines the "service locator" anti-pattern. Because the container is a complex object with variable contents, code receiving the container is hard to test. @@ -160,3 +167,5 @@ Instead, in the service providing a given feature, use something like `appServic In most cases, the value obtained thus will be a `struct` or a `func`, ready to be used without further data from the container. + +See a complete demo in [examples/demo.go](examples/demo.go). diff --git a/examples/demo.go b/examples/demo.go new file mode 100644 index 0000000..e0678d4 --- /dev/null +++ b/examples/demo.go @@ -0,0 +1,17 @@ +package main + +import ( + "log" + "os" + + "github.com/fgm/izidic/examples/di" +) + +func main() { + dic := di.Resolve(os.Stdout, os.Args[0], os.Args[1:]) + app := dic.MustService("app").(di.App) + log.Printf("app: %#v\n", app) + if err := app(); err != nil { + os.Exit(1) + } +} diff --git a/examples/di/app.go b/examples/di/app.go new file mode 100644 index 0000000..c3baad8 --- /dev/null +++ b/examples/di/app.go @@ -0,0 +1,13 @@ +package di + +import "log" + +// App represents whatever an actual application as a function would be. +type App func() error + +func makeAppFeature(name string, logger *log.Logger) App { + return func() error { + logger.Println(name) + return nil + } +} diff --git a/examples/di/di.go b/examples/di/di.go new file mode 100644 index 0000000..64bb548 --- /dev/null +++ b/examples/di/di.go @@ -0,0 +1,57 @@ +package di + +import ( + "io" + "log" + + "github.com/fgm/izidic" +) + +// Container is an application-specific wrapper for a basic izidic container, +// adding typed accessors for simpler use by application code, obviating the need +// for type assertions. +type Container struct { + izidic.Container +} + +// Logger is a typed service accessor. +func (c *Container) Logger() *log.Logger { + return c.MustService("logger").(*log.Logger) +} + +// Name is a typed parameter accessor. +func (c *Container) Name() string { + return c.MustParam("name").(string) +} + +// Resolve is the location where the parameters and services in the container +// +// are assembled and the container readied for use. +func Resolve(w io.Writer, name string, args []string) izidic.Container { + dic := izidic.New() + dic.Store("name", name) + dic.Store("writer", w) + dic.Register("app", appService) + dic.Register("logger", loggerService) + dic.Freeze() + return dic +} + +func appService(dic izidic.Container) (any, error) { + wdic := Container{dic} // wrapped Container with typed accessors + logger := wdic.Logger() // typed service instance: *log.Logger + name := wdic.Name() // typed parameter value: string + appFeature := makeAppFeature(name, logger) + return appFeature, nil +} + +// loggerService is an izidic.Service also containing a one-time initialization action. +// +// Keep in mind that the initialization will only be performed once the service has +// actually been instantiated. +func loggerService(dic izidic.Container) (any, error) { + w := dic.MustParam("writer").(io.Writer) + log.SetOutput(w) // Support dependency code not taking an injected logger. + logger := log.New(w, "", log.LstdFlags) + return logger, nil +} diff --git a/izidic-100x100.png b/izidic-100x100.png new file mode 100644 index 0000000..9f55f90 Binary files /dev/null and b/izidic-100x100.png differ diff --git a/izidic.go b/izidic.go index ebbb12d..18a79ed 100644 --- a/izidic.go +++ b/izidic.go @@ -5,7 +5,7 @@ // before those are actually defined. // // Container writes are not concurrency-safe, so they are locked with Container.Freeze() -// after the initial setup, which is assumed to be non-concurrent +// after the initial setup, which is assumed to be non-concurrent. package izidic import ( @@ -22,10 +22,22 @@ import ( // which should then be type-asserted before use. // // Any access to a service from the container returns the same instance. -type Service func(dic *Container) (any, error) +type Service func(dic Container) (any, error) + +// Container represents any implementation of a dependency injection container. +type Container interface { + Freeze() + MustParam(name string) any + MustService(name string) any + Names() map[string][]string + Param(name string) (any, error) + Register(name string, fn Service) + Store(name string, param any) + Service(name string) (any, error) +} -// Container is the container, holding both parameters and services -type Container struct { +// container is the container, holding both parameters and services +type container struct { sync.RWMutex // Lock for service instances frozen bool parameters map[string]any @@ -35,12 +47,28 @@ type Container struct { // Freeze converts the container from build mode, which does not support // concurrency, to run mode, which does. -func (dic *Container) Freeze() { +func (dic *container) Freeze() { dic.frozen = true } +func (dic *container) MustParam(name string) any { + p, err := dic.Param(name) + if err != nil { + panic(err) + } + return p +} + +func (dic *container) MustService(name string) any { + instance, err := dic.Service(name) + if err != nil { + panic(err) + } + return instance +} + // Names returns the names of all the parameters and instances defined on the container. -func (dic *Container) Names() map[string][]string { +func (dic *container) Names() map[string][]string { dump := map[string][]string{ "params": make([]string, 0, len(dic.parameters)), "services": make([]string, 0, len(dic.serviceDefs)), @@ -58,24 +86,27 @@ func (dic *Container) Names() map[string][]string { return dump } -// Register registers a service with the container. -func (dic *Container) Register(name string, fn Service) { - if dic.frozen { - panic("Cannot register services on frozen container") +func (dic *container) Param(name string) (any, error) { + dic.RLock() + defer dic.RUnlock() + + p, found := dic.parameters[name] + if !found { + return nil, fmt.Errorf("parameter not found: %q", name) } - dic.serviceDefs[name] = fn + return p, nil } -// Store stores a parameter in the container. -func (dic *Container) Store(name string, param any) { +// Register registers a service with the container. +func (dic *container) Register(name string, fn Service) { if dic.frozen { - panic("Cannot store parameters on frozen container") + panic("Cannot register services on frozen container") } - dic.parameters[name] = param + dic.serviceDefs[name] = fn } // Service returns the single instance of the requested service on success. -func (dic *Container) Service(name string) (any, error) { +func (dic *container) Service(name string) (any, error) { // Reuse existing instance if any. dic.RLock() instance, found := dic.services[name] @@ -86,7 +117,7 @@ func (dic *Container) Service(name string) (any, error) { // Otherwise instantiate. No lock because no concurrent writes can happen: // - during build, recursive calls may happen, but not concurrently - // - after freeze, no new services may be created: see Container.Register + // - after freeze, no new services may be created: see container.Register service, found := dic.serviceDefs[name] if !found { return nil, fmt.Errorf("service not found: %q", name) @@ -96,7 +127,7 @@ func (dic *Container) Service(name string) (any, error) { // this step than there are services defined in the container, then resolution // for at least one service was attempted more than once, which implies a // dependency cycle. - const funcName = "github.com/fgm/izidic.(*Container).Service" + const funcName = "github.com/fgm/izidic.(*container).Service" // We need a vastly oversized value to cover the case of deeply nested dic.Service() calls. pcs := make([]uintptr, 1e6) n := runtime.Callers(1, pcs) @@ -128,36 +159,17 @@ func (dic *Container) Service(name string) (any, error) { return instance, nil } -func (dic *Container) MustService(name string) any { - instance, err := dic.Service(name) - if err != nil { - panic(err) - } - return instance -} - -func (dic *Container) Param(name string) (any, error) { - dic.RLock() - defer dic.RUnlock() - - p, found := dic.parameters[name] - if !found { - return nil, fmt.Errorf("parameter not found: %q", name) - } - return p, nil -} - -func (dic *Container) MustParam(name string) any { - p, err := dic.Param(name) - if err != nil { - panic(err) +// Store stores a parameter in the container. +func (dic *container) Store(name string, param any) { + if dic.frozen { + panic("Cannot store parameters on frozen container") } - return p + dic.parameters[name] = param } // New creates a container ready for use. -func New() *Container { - return &Container{ +func New() Container { + return &container{ RWMutex: sync.RWMutex{}, parameters: make(map[string]any), serviceDefs: make(map[string]Service), diff --git a/izidic_test.go b/izidic_test.go index 70afc1b..e173a43 100644 --- a/izidic_test.go +++ b/izidic_test.go @@ -1,4 +1,4 @@ -package izidic +package izidic_test import ( "errors" @@ -6,14 +6,15 @@ import ( "strings" "testing" + "github.com/fgm/izidic" "github.com/google/go-cmp/cmp" ) var ( - s1 = func(c *Container) (any, error) { + s1 = func(c izidic.Container) (any, error) { return "s1", nil } - s2 = func(c *Container) (any, error) { + s2 = func(c izidic.Container) (any, error) { s1, err := c.Service("s1") if err != nil { return nil, fmt.Errorf("could not get service s1: %w", err) @@ -39,7 +40,7 @@ func TestContainer_Param(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - dic := New() + dic := izidic.New() for _, kv := range test.stored { dic.Store(kv.k, kv.v) } @@ -67,7 +68,7 @@ func TestContainer_MustParam(t *testing.T) { t.Fatalf("got %q, but expected %q", actual.Error(), expected) } }() - dic := New() + dic := izidic.New() // Happy path dic.Store("k", "v") actual := dic.MustParam("k").(string) @@ -81,7 +82,7 @@ func TestContainer_MustParam(t *testing.T) { func TestContainer_Service(t *testing.T) { const expected = "s1s2" - dic := New() + dic := izidic.New() dic.Register("s1", s1) dic.Register("s2", s2) s, err := dic.Service("s2") @@ -110,9 +111,9 @@ func TestContainer_MustService_Missing(t *testing.T) { t.Fatalf("got %q, but expected %q", actual.Error(), expected) } }() - dic := New() + dic := izidic.New() // Happy path - s := func(*Container) (any, error) { return 42, nil } + s := func(izidic.Container) (any, error) { return 42, nil } dic.Register("s", s) actual := dic.MustService("s").(int) expected, _ := s(dic) @@ -126,10 +127,10 @@ func TestContainer_MustService_Missing(t *testing.T) { func TestContainer_Service_Failing(t *testing.T) { instErr := errors.New("failed") - s := func(dic *Container) (any, error) { + s := func(dic izidic.Container) (any, error) { return nil, instErr } - dic := New() + dic := izidic.New() dic.Register("s", s) actualService, err := dic.Service("s") if actualService != nil { @@ -146,12 +147,12 @@ func TestContainer_Service_Failing(t *testing.T) { func TestContainer_Service_Reuse(t *testing.T) { const name = "s" counter := 0 - service := func(dic *Container) (any, error) { + service := func(dic izidic.Container) (any, error) { counter++ return counter, nil } - dic := New() + dic := izidic.New() dic.Register(name, service) actual := dic.MustService(name).(int) if actual != 1 { @@ -168,7 +169,7 @@ func TestContainer_Names(t *testing.T) { vpt *string vt string ) - dic := New() + dic := izidic.New() dic.Store("p1", vt) dic.Store("p2", vpt) dic.Register("s1", s1) @@ -187,11 +188,11 @@ func TestContainer_Names(t *testing.T) { func TestContainer_Freeze(t *testing.T) { tests := [...]struct { name string - attempt func(*Container) + attempt func(container izidic.Container) expected string }{ - {"register", func(dic *Container) { dic.Register("p", nil) }, "Cannot register services on frozen container"}, - {"store", func(dic *Container) { dic.Store("p", "v") }, "Cannot store parameters on frozen container"}, + {"register", func(dic izidic.Container) { dic.Register("p", nil) }, "Cannot register services on frozen container"}, + {"store", func(dic izidic.Container) { dic.Store("p", "v") }, "Cannot store parameters on frozen container"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -205,7 +206,7 @@ func TestContainer_Freeze(t *testing.T) { t.Fatalf("Got %s but expected %s", msg, test.expected) } }() - dic := New() + dic := izidic.New() dic.Freeze() test.attempt(dic) }) @@ -214,21 +215,21 @@ func TestContainer_Freeze(t *testing.T) { func TestContainer_Service_CircularDeps(t *testing.T) { // We build a 3-level dependency because some simpler strategies to address 2-level (mutual) dependencies do not catch more complex ones, - sA := func(c *Container) (any, error) { + sA := func(c izidic.Container) (any, error) { sC, err := c.Service("sC") if err != nil { return nil, fmt.Errorf("could not get service sC: %w", err) } return sC.(string) + "sA", nil } - sB := func(c *Container) (any, error) { + sB := func(c izidic.Container) (any, error) { sA, err := c.Service("sA") if err != nil { return nil, fmt.Errorf("could not get service sA: %w", err) } return sA.(string) + "sB", nil } - sC := func(c *Container) (any, error) { + sC := func(c izidic.Container) (any, error) { sB, err := c.Service("sB") if err != nil { return nil, fmt.Errorf("could not get service sB: %w", err) @@ -236,7 +237,7 @@ func TestContainer_Service_CircularDeps(t *testing.T) { return sB.(string) + "sC", nil } - dic := New() + dic := izidic.New() dic.Register("sA", sA) dic.Register("sB", sB) dic.Register("sC", sC)