go-nacelle / nacelle

The Go service framework.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Proposal for modified service container access (Dependency Pulling instead of Injection?)

aphistic opened this issue · comments

The way dependency injection in Nacelle works has always made me feel a bit weird due to the requirement for making the fields exported and it being something of a "you need to know how this works to initialize it". I haven't really been able to come up with a better way to do it where things are automatically injected, but I was thinking there could possibly be a second Init method signature available that exposes the service container as a parameter in addition to (or maybe instead of if you like the idea of getting the config from the service container in #3). It doesn't quite solve the discoverability issue, but I think it's a step in the right direction because you'll get an error when you try to get the service instead of when you trying to use something that was injected. I haven't quite figured out exactly how to implement it in a way that doesn't make the Initializer and Process interfaces a little too flexible or inflexible. So far I've been thinking that since Go provides a way to assert various interfaces there could be something like this:

// I don't like this but I'm not sure how else to do it
type Initializer interface{}

type ServiceInitializer interface{
	InitWithServices(config nacelle.Config, services nacelle.ServiceContainer) error
}

type BasicInitializer interface{
	Init(config nacelle.Config) error
}

Then when a process or initializer is starting up it would check if either of those interfaces fit and call that. I don't like how open the standard Initializer interface is here, but it's there to work with existing code that might reference it as well as give users a type that makes it obvious what a parameter should be.

With the services provided in this way, it would then be up to the user to Get their services from the container so it's not really DI any more... But I think it gives a more direct way of knowing whether your code will run without a "run it and find out we panic". Instead the user would get a legit error that their dependency doesn't exist in the container.

As I was thinking about this, I also thought it might be nice to change the ServiceContainer slightly. Instead of taking a string as the key, I think it would be good to change it to an interface{} similar to how context.Context works. This would allow usage of a strong(er) type key instead of magic strings. This would be backward compatible because all the existing code would still be passing in the same strings, which would still work as the keys for the services. The services that nacelle registers for you could still register with the string keys, but also with an additional new LoggerService, ServiceContainerService, etc that make accessing those services more discoverable if you're using the service container directly.

Here's what the init might look like with both of these updates. It's more verbose but I think it provides flexibility for people to choose whether they'd like to use the DI or pull services directly:

func (p *Process) InitWithServices(services nacelle.ServiceContainer) error {
	rawConfig, err := services.Get(nacelle.ConfigService)
	if err != nil {
		return err
	}
	config := rawConfig.(nacelle.Config)

	rawHealth, err := services.Get(nacelle.HealthService)
	if err != nil {
		return err
	}
	health := rawHealth.(nacelle.Health)

	// Or if you don't care about panics:
	logger := services.MustGet(nacelle.LoggerService).(nacelle.Logger)
}

As long as I'm putting all this in here maybe I can sneak in another small ServiceContainer note. One of my teammates had mentioned one of his problems with other DI frameworks is there's no way to know where the value for a service was actually set, which makes troubleshooting harder. I was wondering if there might be a way to attach the call location of Set to the service's value and then provide a way to access that info in case someone wants to use it for troubleshooting?

Ok, maybe I haven't fully digested this yet but I think I see the direction here. Let me know if I'm mistaken in any assumption here.

Your team still want to use a service container, but you just don't want any automatic injection (especially via struct tags) because it's a bit too magical (which is arguably non-idiomatic for Go) and would rather explicitly call Get on the service container. So this only has to do with initialization and not with the service container itself.

If this is the case, then consider an alternative solution that can done completely inside go-nacelle/service to avoid use of struct tags without changing any other functionality:

// Called by the service container instead of using struct tags
// Called _before_ Init
func (p *Process) Inject(services nacelle.ServiceContainer) error {
rawConfig, err := services.Get(nacelle.ConfigService)
	if err != nil {
		return err
	}
	config := rawConfig.(nacelle.Config)

	rawHealth, err := services.Get(nacelle.HealthService)
	if err != nil {
		return err
	}
	health := rawHealth.(nacelle.Health)

	// Or if you don't care about panics:
	logger := services.MustGet(nacelle.LoggerService).(nacelle.Logger)
}

// Called by process package _after_ Inject
func (p *Process) Init() error {
	// Regular init stuff
}

We have similar PostInject hooks exposed for the service container, so also having a "nah bro, I got this" hook seems to fit in. (And others like the sql and json packages do this for deserialization of values, so it's also idiomatic.)

Note: For this to work we still need to make the config available in the service container (as you described in #3).

This proposal is separate from stuff for work. It's just something I thought of while I was working on other stuff. The mention of my team here was more as something I remembered someone mentioning as a frustration they had with using other DI frameworks (not knowing where something in the service container/injection framework/whatever was being set), and I thought if a refactoring of some of the service container code was going to happen it was something worth mentioning. If the config availability in the bootstrap method is implemented we would forego the service container completely and do all our own initialization and passing in dependencies ourselves.

As far as the Inject method, I like it! It does avoid the problem with having an alternative Init method and still provides direct access to the service container without the struct tags AND would let users keep their service references unexported.

Based on your updated example it seems you'd be on board with making the key an interface{} and making config available from the container as well?

Based on your updated example it seems you'd be on board with making the key an interface{} and making config available from the container as well?

Making the config available is a non-issue for me. Making the key an interface would break compatibility with struct tags, so I'm less inclined to do that. Is the purpose just increased type safety (a la getting values from context objects)?

Making the config available is a non-issue for me. Making the key an interface would break compatibility with struct tags, so I'm less inclined to do that. Is the purpose just increased type safety (a la getting values from context objects)?

Yeah, but I think it's about discoverability as much as type safety. If you have a concrete variable of a certain type it'll not only be checked at compile time to make sure it's right, but it'll also be shown as an option by code hinting tools as well.

I was thinking that in moving to an interface key it would still keep compatibility with existing code and would also be compatible with any future struct tag code, you would just need to use a string key in order to do it. This way the feature would be more of an opt-in, so you could still use string keys and struct tags if you want but if you didn't you could use it the way I proposed.

I think there's a solution here and we're dancing close to it. My concern here is that once you use non-strings you would require code changes in order to adopt the struct tag use (which I still prefer as the suggested way to use the service package).

What if instead of just interface{} we require a ServiceKey interface that has a Tag() string method? That way you can do the same string-alias type but there's a way to transition into using struct tags.

type Key string

func (k Key) Tag() string { return string(k) }

const serviceKey Key = "foo"

To make sure I'm understanding correctly, the container would then look like this?

type ServiceKey interface {
    Tag() string
}

func (s *ServiceContainer) Set(key ServiceKey, val interface{}) error {}
func (s *ServiceContainer) Get(key ServiceKey) (interface{}, error) {}

Sounds good to me!

Ok, update after some brainstorming:

  • Service keys will have type interface{} instead of string
  • Service keys that happen to be asserted into a string type or implement a particular optional interface with a Tag method will be equivalent to other service keys with the same value.

This retains backwards compatibility and allows a way forward for struct tags to be supported fro non-string service keys.