avaje / avaje-config

Application configuration / properties loading for JVM applications
https://avaje.io/config
Apache License 2.0
50 stars 8 forks source link

Feature request: Support configuration injection #32

Closed hoangdt84 closed 2 years ago

hoangdt84 commented 2 years ago

Hi,

Could you please add something like micronaut's @Value / @Property (see micronaut's document)?

rbygrave commented 2 years ago

Could we do this - yes, the question is should we do this.

So for example:

@Singleton
public class EngineImpl implements Engine {

    @Value("${my.engine.cylinders:6}")  
    protected int cylinders;
    ...
}

Could be done today as:

import io.avaje.config.Config;

@Singleton
public class EngineImpl implements Engine {

    protected int cylinders = Config.getInt("my.engine.cylinders", 6);

... and in fact we can make that a final field:

    protected final int cylinders = Config.getInt("my.engine.cylinders", 6);

There is some background design thinking around this so we need to outline that here.


Background

Both Spring and Micronaut have a @Value and by implication they have chosen to combine "external configuration" in with "dependency injection". With avaje-inject we could also do that but we (mostly I) have desired to keep these 2 things separate for what I believe are fairly good reasons. So lets look at those reasons.

We could for implement this via source code generation with avaje-inject as:

public class EngineImpl$Proxy extends EngineImpl {

  public EngineImpl$Proxy() { // match super constructor, 
    super();
    this.cylinders = Config.getInt("my.engine.cylinders", 6);
  }

}

1. Timing of getting the configuration

We can see that cylinders is only set after the super(). Any code that tries to use cylinders before that would get a 0 (or null with Integer etc). This is relatively obvious to experienced devs but it is a source of bugs for less experienced devs. That is, if we don't use @Value and instead use Config.getInt() the field is initialised just like any normal field. That is, @Value fields have delayed initialisation and this can trip people up / be a source of bugs.

2. Dynamic configuration

If we go from needing the configuration read and set ONCE AT STARTUP to being read each time and potentially changing (aka dynamic configuration). Then we either need to change away from using @Value OR use the "Refreshable scope" concept.

I'd argue that when using Config.getInt() we can just use it anywhere - field, final field, static final field, in a method (dynamic configuration). There isn't a big shift between static configuration and dynamic configuration.

2.B "Refreshable scope"

As a consequence of "dynamic configuration", both Spring and Micronaut have the concept of "refreshable beans" / "refreshable context". With avaje-inject we explicitly do NOT have that concept as we'd get no value from "re-wiring the graph". Spring and Micronaut somewhat need it to re-read the @Value configuration - I'd argue that is because they have combined "external configuration" with "dependency injection wiring" ... so any dynamic configuration need with @Value means the bean needs to be refreshed.

That is, avaje-inject doesn't need "refreshable beans" because we expect "external dynamic configuration" to be done independently (for example, by using avaje-config). Instead, avaje-inject creates effectively immutable BeanScope.

3. avaje-config

I really like avaje-config. It's simple, extendable, and pretty mature. It was originally part of ebean orm and I extracted it into it's own project. If we supported @Value in avaje-inject then it would have to pick a "configuration implementation" and that would be avaje-config. However, currently avaje-inject users are free to do whatever they like here with external configuration and I know that some of them choose other configuration libraries.


Q: So could we support @Value?

A: Yes we could, it means that we need a $Proxy (just like we do to support AOP) and the generated code would pretty much be:

public class EngineImpl$Proxy extends EngineImpl {

  public EngineImpl$Proxy() { // match constructor of EngineImpl 
    super();
    this.cylinders = Config.getInt("my.engine.cylinders", 6);
  }

}

Q: So should we do this ?

Well, we could ... and then ideally document it in such a way that people think about their choices.

One of the interesting things about the code generation approach is that adding something like this can often result in ZERO added complexity to the runtime part of the library .. and instead just extra complexity is added to the code generator. I suspect that is the case here.

hoangdt84 commented 2 years ago

@rbygrave thanks a lot for your explanation. It makes perfect sense to me.