Skip to content

Commit

Permalink
feat: auto-magically inject dependencies into struct
Browse files Browse the repository at this point in the history
  • Loading branch information
samber committed Dec 30, 2023
1 parent 9524b3b commit 81b3d8f
Show file tree
Hide file tree
Showing 15 changed files with 516 additions and 26 deletions.
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ I love the **short name** for such a utility library. This name is the sum of `D
- Eager loading
- Lazy loading
- Transient loading
- Tag-based invocation
- **🧙‍♂️ Service aliasing**
- Implicit (provide struct, invoke interface)
- Explicit (provide struct, bind interface, invoke interface)
Expand All @@ -45,10 +46,10 @@ I love the **short name** for such a utility library. This name is the sum of `D
- **📦 Scope (a.k.a module) tree**
- Visibility control
- Dependency grouping
- **📤 Injector**
- **📤 Container**
- Dependency graph resolution and visualization
- Default injector
- Injector cloning
- Default container
- Container cloning
- Service override
- **🌈 Lightweight, no dependencies**
- **🔅 No code generation**
Expand Down
32 changes: 32 additions & 0 deletions di.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package do

import (
"fmt"
"reflect"
)

// NameOf returns the name of the service in the DI container.
Expand Down Expand Up @@ -130,3 +131,34 @@ func InvokeNamed[T any](i Injector, name string) (T, error) {
func MustInvokeNamed[T any](i Injector, name string) T {
return must1(InvokeNamed[T](i, name))
}

// InvokeStruct invokes services located in struct properties.
// The struct fields must be tagged with `do:""` or `do:"name"`, where `name` is the service name in the DI container.
// If the service is not found in the DI container, an error is returned.
// If the service is found but not assignable to the struct field, an error is returned.
func InvokeStruct[T any](i Injector) (*T, error) {
output := empty[T]()
value := reflect.ValueOf(&output)

// Check if the empty value is a struct (before passing a pointer to reflect.ValueOf).
// It will be checked in invokeByTags, but the error message is different.
if value.Kind() != reflect.Ptr || value.Elem().Kind() != reflect.Struct {
return nil, fmt.Errorf("DI: not a struct")
}

err := invokeByTags(i, value)
if err != nil {
return nil, err
}

return &output, nil
}

// InvokeStruct invokes services located in struct properties.
// The struct fields must be tagged with `do:""` or `do:"name"`, where `name` is the service name in the DI container.
// If the service is not found in the DI container, an error is returned.
// If the service is found but not assignable to the struct field, an error is returned.
// It panics on error.
func MustInvokeStruct[T any](i Injector) *T {
return must1(InvokeStruct[T](i))
}
96 changes: 96 additions & 0 deletions di_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -539,3 +539,99 @@ func TestMustInvokeNamed(t *testing.T) {
is.EqualValues(42, instance1)
})
}

func TestInvokeStruct(t *testing.T) {
is := assert.New(t)

i := New()
ProvideValue(i, &eagerTest{foobar: "foobar"})

// no dependencies
test0, err := InvokeStruct[eagerTest](i)
is.Nil(err)
is.Empty(test0)

// not a struct
test1, err := InvokeStruct[int](i)
is.Nil(test1)
is.Equal("DI: not a struct", err.Error())

// exported field - generic type
type hasExportedEagerTestDependency struct {
EagerTest *eagerTest `do:""`
}
test2, err := InvokeStruct[hasExportedEagerTestDependency](i)
is.Nil(err)
is.Equal("foobar", test2.EagerTest.foobar)

// unexported field
type hasNonExportedEagerTestDependency struct {
eagerTest *eagerTest `do:""`
}
test3, err := InvokeStruct[hasNonExportedEagerTestDependency](i)
is.Nil(err)
is.Equal("foobar", test3.eagerTest.foobar)

// not found
type dependencyNotFound struct {
eagerTest *hasNonExportedEagerTestDependency `do:""`

Check failure on line 577 in di_test.go

View workflow job for this annotation

GitHub Actions / lint

field `eagerTest` is unused (unused)
}
test4, err := InvokeStruct[dependencyNotFound](i)
is.Equal(serviceNotFound(i, inferServiceName[*hasNonExportedEagerTestDependency]()).Error(), err.Error())
is.Nil(test4)

// use tag
type namedDependency struct {
eagerTest *eagerTest `do:"int"`

Check failure on line 585 in di_test.go

View workflow job for this annotation

GitHub Actions / lint

field `eagerTest` is unused (unused)
}
test5, err := InvokeStruct[namedDependency](i)
is.Equal(serviceNotFound(i, inferServiceName[int]()).Error(), err.Error())
is.Nil(test5)

// named service
ProvideNamedValue(i, "foobar", 42)
type namedService struct {
EagerTest int `do:"foobar"`
}
test6, err := InvokeStruct[namedService](i)
is.Nil(err)
is.Equal(42, test6.EagerTest)

// use tag but wrong type
type namedDependencyButTypeMismatch struct {
EagerTest *int `do:"*github.com/samber/do/v2.eagerTest"`
}
test7, err := InvokeStruct[namedDependencyButTypeMismatch](i)
is.Equal("DI: field 'EagerTest' is not assignable to service *github.com/samber/do/v2.eagerTest", err.Error())
is.Nil(test7)

// use a custom tag
i = NewWithOpts(&InjectorOpts{StructTagKey: "hello"})
ProvideNamedValue(i, "foobar", 42)
type namedServiceWithCustomTag struct {
EagerTest int `hello:"foobar"`
}
test8, err := InvokeStruct[namedServiceWithCustomTag](i)
is.Nil(err)
is.Equal(42, test8.EagerTest)
}

func TestMustInvokeStruct(t *testing.T) {
is := assert.New(t)
i := New()

// use a custom tag
type namedServiceWithCustomTag struct {
EagerTest int `do:"foobar"`
}

is.Panics(func() {
_ = MustInvokeStruct[namedServiceWithCustomTag](i)
})

ProvideNamedValue(i, "foobar", 42)
is.NotPanics(func() {
test := MustInvokeStruct[namedServiceWithCustomTag](i)
is.Equal(42, test.EagerTest)
})
}
7 changes: 4 additions & 3 deletions docs/docs/about.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ I love the **short name** for such a utility library. This name is the sum of `D
- Eager loading
- Lazy loading
- Transient loading
- Tag-based invocation
- **🧙‍♂️ Service aliasing**
- Implicit (provide struct, invoke interface)
- Explicit (provide struct, bind interface, invoke interface)
Expand All @@ -40,10 +41,10 @@ I love the **short name** for such a utility library. This name is the sum of `D
- **📦 Scope (a.k.a module) tree**
- Visibility control
- Dependency grouping
- **📤 Injector**
- **📤 Container**
- Dependency graph resolution and visualization
- Default injector
- Injector cloning
- Default container
- Container cloning
- Service override
- **🌈 Lightweight, no dependencies**
- **🔅 No code generation**
Expand Down
71 changes: 56 additions & 15 deletions docs/docs/service-invocation/service-invocation.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,41 +43,82 @@ i := do.New()
do.ProvideNamedValue(i, "config.ip", "127.0.0.1")
do.Provide(i, func(i do.Injector) (*MyService, error) {
return &MyService{
IP: do.MustInvokeNamed(i, "config.ip"),
IP: do.MustInvokeNamed(i, "config.ip"),
}, nil
})

myService, err := do.Invoke[*MyService](i)
```

## Other services as dependencies
## Auto-magically load service

You can also use the `do.InvokeStruct` function to auto-magically feed a service with its dependencies. The fields can be either exported or not.

The `do:""` tag indicates the DI must infer the service name from its type (equivalent to `do.Invoke[*logrus.Logger](i)`).

```go
type MyService struct {
// injected automatically
serverPort int `do:"config.listen_port"`
logger *logrus.Logger `do:""`
postgresqlClient *PostgresqlClient `do:""`
dataProcessingService *DataProcessingService `do:""`

// other things, not related to DI
mu sync.Mutex
}
```

Then add `*MyService` to the list of available services.

```go
err := do.Provide[*MyService](injector, func (i do.Injector) (*MyService, error) {
return do.InvokeStruct[MyService](i)
})
// or
err := Provide[*MyService](i, InvokeStruct[MyService])
```

:::info

Nested structs are not supported.

:::

:::warning

This feature relies on reflection and is therefore not recommended for performance-critical code or serverless environments. Please do your due diligence with proper benchmarks.

:::

## Error handling

Any panic during lazy loading is converted into a Go `error`.

An error is returned on missing service.

## Invoke once

A service might rely on other services. In that case, you should invoke dependencies in the service provider instead of storing the injector for later.

```go
// ❌ bad
type MyService struct {
injector do.Injector
injector do.Injector
}
func NewMyService(i do.Injector) (*MyService, error) {
return &MyService{
injector: i,
}, nil
return &MyService{
injector: i,
}, nil
}

// ✅ good
type MyService struct {
dependency *MyDependency
}
func NewMyService(i do.Injector) (*MyService, error) {
return &MyService{
dep: do.MustInvoke[*MyDependency](i), // <- recursive invocation on service construction
}, nil
return &MyService{
dep: do.MustInvoke[*MyDependency](i), // <- recursive invocation on service construction
}, nil
}
```

## Error handling

Any panic during lazy loading is converted into a Go `error`.

An error is returned on missing service.
2 changes: 1 addition & 1 deletion docs/docs/service-lifecycle/shutdowner.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ When the `do.Shutdown[type]()` or the `injector.Shutdown()` function is called,

## Trigger shutdown

A shutdown can be triggered on a root injector:
A shutdown can be triggered on a root scope:

```go
// on demand
Expand Down
64 changes: 64 additions & 0 deletions examples/struct/example.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package main

import (
"github.com/samber/do/v2"
)

/**
* Wheel
*/
type Wheel struct {
}

/**
* Engine
*/
type Engine struct {
}

/**
* Car
*/
type Car struct {
Engine *Engine `do:""` // <- injected by MustInvokeStruct
Wheels []*Wheel
}

func (c *Car) Start() {
println("vroooom")
}

/**
* Run example
*/
func main() {
injector := do.New()

// provide wheels
do.ProvideNamedValue(injector, "wheel-1", &Wheel{})
do.ProvideNamedValue(injector, "wheel-2", &Wheel{})
do.ProvideNamedValue(injector, "wheel-3", &Wheel{})
do.ProvideNamedValue(injector, "wheel-4", &Wheel{})

// provide car
do.Provide(injector, func(i do.Injector) (*Car, error) {
car := do.MustInvokeStruct[Car](i)
car.Wheels = []*Wheel{
do.MustInvokeNamed[*Wheel](i, "wheel-1"),
do.MustInvokeNamed[*Wheel](i, "wheel-2"),
do.MustInvokeNamed[*Wheel](i, "wheel-3"),
do.MustInvokeNamed[*Wheel](i, "wheel-4"),
}

return car, nil
})

// provide engine
do.Provide(injector, func(i do.Injector) (*Engine, error) {
return &Engine{}, nil
})

// start car
car := do.MustInvoke[*Car](injector)
car.Start()
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ go 1.18
//

require (
github.com/samber/go-type-to-string v1.1.0
github.com/samber/go-type-to-string v1.2.0
github.com/stretchr/testify v1.8.3
go.uber.org/goleak v1.2.1
)
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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/samber/go-type-to-string v1.1.0 h1:HDCOY96iOnkzRF5D/FeZV5W4k7Jg/Qg26GhgOD5cmNI=
github.com/samber/go-type-to-string v1.1.0/go.mod h1:jpU77vIDoIxkahknKDoEx9C8bQ1ADnh2sotZ8I4QqBU=
github.com/samber/go-type-to-string v1.2.0 h1:Pvdqx3r/EHn9/DTKoW6RoHz/850s5yV1vA6MqKKG5Ys=
github.com/samber/go-type-to-string v1.2.0/go.mod h1:jpU77vIDoIxkahknKDoEx9C8bQ1ADnh2sotZ8I4QqBU=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
Expand Down
3 changes: 2 additions & 1 deletion go.work.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvSc
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/samber/go-type-to-string v1.2.0 h1:Pvdqx3r/EHn9/DTKoW6RoHz/850s5yV1vA6MqKKG5Ys=
github.com/samber/go-type-to-string v1.2.0/go.mod h1:jpU77vIDoIxkahknKDoEx9C8bQ1ADnh2sotZ8I4QqBU=
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
Expand Down
Loading

0 comments on commit 81b3d8f

Please sign in to comment.