defval / di

🛠 A full-featured dependency injection container for go programming language.
MIT License
232 stars 13 forks source link

How to build a service conditionally? #8

Closed erickskrauch closed 4 years ago

erickskrauch commented 4 years ago

Hello.

I'm considering introducing DI into my project. Your DI implementation container looks good enough :) But I have a question: how to create services that are created through user's configuration? Here's an example:

package main

import (
    "github.com/goava/di"
    "github.com/spf13/viper"
)

type ServiceInterface interface {
    Do()
}

type DependencyA interface {}
type ServiceImplementationA struct {DependencyA}
func (s *ServiceImplementationA) Do() {}

type DependencyB interface {}
type ServiceImplementationB struct {DependencyB}
func (s *ServiceImplementationB) Do() {}

func NewService(config *viper.Viper) ServiceInterface {
    if viper.GetString("preferred_service") == "B" {
        // Resolve as ServiceImplementationB
    }

    // Otherwise resolve as ServiceImplementationA
}

func main() {
    container, _ := di.New(
        di.Provide(viper.New),
        di.Provide(NewService),
    )
    var service ServiceInterface
    container.Resolve(&service)
}

Depending on the configuration, I want to create a service interface implementation. But each implementation has its own dependencies list (I have not provided them manually in the example). I need a way to somehow call container.Resolve(&concreteImplementation) from the NewService function.

Is there any way to do this? Or maybe I just misunderstand how it should be done at all?

defval commented 4 years ago

@erickskrauch Hi! Thanks for your interest =)

In the current implementation, the change of dependency graph after compilation is restricted for a more clear injection mechanism. This means that you can't add a provider of type based on the other providing result. The dependency graph must be formed before it is compiled and it can't change in the future.

In this case, you can create a configuration outside a container and change the list of options:

config := viper.New()
// config initialization code
// ...
var options []di.Option
switch config.GetString("preferred_service") {
case "A":
    options = append(options, di.Provide(NewServiceA))
case "B":
    options = append(options, di.Provide(NewServiceB))
}
options = append(options,
    di.Provide(func() *viper.Viper { return config }), // provide already existing config
)
container, err := di.New(options...)

I'm just thinking about how to make this functionality easy and clear.

Now, we have some ideas:

I will form some proposals for this in the near future.

erickskrauch commented 4 years ago

Thanks for the reply!

The first idea was to create some sort of reduced container interface (without methods that would modify the dependency graph) that could be accepted as service dependency and will let to call the Invoke, Resolve and Has methods. But I think disclosing the last two methods makes it somehow close to some kind of service locator :)

Updated example:

package main

import (
    "github.com/goava/di"
    "github.com/spf13/viper"
)

type ServiceInterface interface {
    Do()
}

type DependencyA interface {}
type ServiceImplementationA struct {DependencyA}
func (s *ServiceImplementationA) Do() {}
func NewServiceA(a DependencyA) *ServiceImplementationA {
    return &ServiceImplementationA{a}
}

type DependencyB interface {}
type ServiceImplementationB struct {DependencyB}
func (s *ServiceImplementationB) Do() {}
func NewServiceB(b DependencyB) *ServiceImplementationB {
    return &ServiceImplementationB{b}
}

func NewService(container di.InjectedContainer, config *viper.Viper) ServiceInterface {
    if viper.GetString("preferred_service") == "B" {
        return container.Invoke(NewServiceB)
    }

    return container.Invoke(NewServiceA)
}

func main() {
    container, _ := di.New(
        di.Provide(viper.New),
        di.Provide(NewService),
        di.Provide(func() DependencyA {
            return &struct{}{}
        }),
        di.Provide(func() DependencyB {
            return &struct{}{}
        }),
    )
    var service ServiceInterface
    container.Resolve(&service)
}
fionera commented 4 years ago

The example looks very identical to my late provide idea ^^

erickskrauch commented 4 years ago

I just thought that this is how the Invoke method turns out to be the most useless because there are no generics in Go and it will be impossible to recognize the return type (and Invoke returns only the err).

Need to think of something else that will allow you to automatically determine the list of dependencies and at the same time not to lose the returned type so that you don't have to perform typecasting.

You need to merge the automatic arguments injection of Invoke's method with Resolve's result passing by reference (I hope you were able to understand that "English" 😅).

erickskrauch commented 4 years ago

Let's call it InvokeWithResult (the name is temp 😅):

func NewService(config *viper.Viper, container di.InjectedContainer) (result ServiceInterface, err error) {
    if config.GetString("preferred_service") == "B" {
        err = container.InvokeWithResult(NewServiceB, &result)
    } else {
        err = container.InvokeWithResult(NewServiceA, &result)
    }
}
defval commented 4 years ago

@erickskrauch, @fionera I lean towards to implement WithCondition() provide option:

func NewConfig() *viper.Viper {
    // initialization code omitted
    return viper.New()
}

func IsServiceA(config *viper.Viper) bool {
    return config.GetString("preferred_service") == "A"
}

func IsServiceB(config *viper.Viper) bool {
    return config.GetString("preferred_service") == "B"
}

func main() {
    c, err := di.New(
        di.Provide(NewConfig),
        di.Provide(NewServiceA, di.WithCondition(IsServiceA)),
        di.Provide(NewServiceB, di.WithCondition(IsServiceB)),
    )
}
fionera commented 4 years ago

When you implement this, you could basically implement it so that di.WithCondition gets executed like a late provide wrapper

func NewService(container di.InjectedContainer, config *viper.Viper) di.Option {
    if viper.GetString("preferred_service") == "B" {
        return di.Provide(NewServiceB)
    }

    return di.Provide(NewServiceA)
}

func main() {
    c, err := di.New(
        di.Provide(NewConfig),
        di.Provide(NewService)
    )
}
erickskrauch commented 4 years ago

Introducing the WithCondition option looks like an acceptable (but very verbose) solution for some simple conditions of choosing the target dependency. But if the logic becomes more complex, each case will have to be rewritten over and over again, which will be the source of hard debuggable errors.

Introducing some magic interface will be a more natural way to solve this task. Maybe you should introduce some di.LateProvide(NewService), which should return di.Option as @fionera suggested. Although this raises the problem that the dependency graph is modified by the service creator result.

That's why my solution with InvokeWithResult seems to be more suitable: the dependency graph doesn't change, but the initial task can be reached :)

defval commented 4 years ago

@erickskrauch WithCondition() separates condition and provide logic. Providing already tested. Condition logic will be easy to test. Even complex cases can be tested and the test will be primitive.

@fionera Provide di.Option needs additional instruments to test on the user side. You must be sure that you have provided the appropriate option.

@erickskrauch Your solution returns interface instead of implementation in the constructor, that doesn't fit the principle "return structs accept interfaces". And does not allow you to use di.As() as it is now. Also, introducing a magic interface is not "go way". Dependency injection already contains a lot of "magic". I wouldn't want to make it more complicated.

And if we are talking about the possibility of container resolving. Then we don't need to add additional methods:

package main

import (
    "errors"
    "fmt"
    "log"
    "net"

    "github.com/goava/di"
)

type ConnectionType string

// NewConnection
func NewConnection(typ ConnectionType, container *di.Container) (net.Conn, error) {
    switch typ {
    case "tcp":
        var conn *net.TCPConn
        return conn, container.Resolve(&conn)
    case "udp":
        var conn *net.UDPConn
        return conn, container.Resolve(&conn)
    }
    return nil, errors.New("unknown connection type")
}

func NewTCPConn() *net.TCPConn {
    return &net.TCPConn{}
}

func NewUDPConn() *net.UDPConn {
    return &net.UDPConn{}
}

func NewConnectionType() ConnectionType {
    return "udp" // change to "tcp" for tcp connection
}

func main() {
    c, err := di.New(
        di.WithCompile(),
        di.Provide(NewConnectionType),
        di.Provide(NewTCPConn),
        di.Provide(NewUDPConn),
        di.Provide(NewConnection),
    )
    if err != nil {
        log.Fatalln(err)
    }
    // hack to provide container
    if err := c.Provide(func() *di.Container { return c }); err != nil {
        log.Fatalln(err)
    }
    if err := c.Compile(); err != nil {
        log.Fatalln(err)
    }
    var conn net.Conn
    if err := c.Resolve(&conn); err != nil {
        log.Fatalln(err)
    }
    switch conn.(type) {
    case *net.TCPConn:
        fmt.Println("TCP")
    case *net.UDPConn:
        fmt.Println("UDP")
    }
}
defval commented 4 years ago

ConnectionType can be replaced with the configuration type and this will work. But it is the hack =D

erickskrauch commented 4 years ago

Your solution returns interface instead of implementation in the constructor, that doesn't fit the principle "return structs accept interfaces".

But the target structures, for the creation of which di is necessary, expect interfaces, not concrete implementations. Otherwise, what is the point of all this at all?

I need to define the interface implementation for the application. But its implementation is chosen based on the configuration. That's why I need some layer, which will allow me to make a decision about the final implementation based on the configuration or other dependencies.


Thus, we come to the conclusion that if we ensure the availability of the container when resolving dependencies, then, in general, the problem is solved. Maybe we should then make container available by default?

defval commented 4 years ago

Sorry for delay, I had a hard time today.

But the target structures, for the creation of which di is necessary, expect interfaces, not concrete implementations. Otherwise, what is the point of all this at all?

di.As() binds implementation to the interface. It is bad practice to return the interface from the constructor.

Thus, we come to the conclusion that if we ensure the availability of the container when resolving dependencies, then, in general, the problem is solved. Maybe we should then make container available by default?

This can be true. I create #9 with some improvements:

Late provide example: https://github.com/goava/di/blob/make-graph-editable/_examples/lateprovide/main.go

It still needs testing, but if it shows up well, we'll release it.

defval commented 4 years ago

I still need to reimplement the acyclicity check and di.Prototype().

erickskrauch commented 4 years ago

Glad we could come to a decision :)

I have already started implementing DI in my project. You can have a look at it: https://github.com/elyby/chrly/tree/di/di. I'll update the code to #9's solution when it'll be ready.

defval commented 4 years ago

Fixes merged. I need some time to prepare the release.