Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support constructor binding on 3rd party classes #23172

Closed
tomfi opened this issue Sep 3, 2020 · 23 comments
Closed

Support constructor binding on 3rd party classes #23172

tomfi opened this issue Sep 3, 2020 · 23 comments
Assignees
Labels
type: enhancement A general enhancement
Milestone

Comments

@tomfi
Copy link

tomfi commented Sep 3, 2020

I use kotlin, and I want to use data classes to describe my properties/options. The data classes themselves sit in core modules that do not depend on spring, and should have minimal 3rd party dependencies.

On top of the modules mentioned above, I built spring auto-configuration modules that create beans, load properties, and utilize spring's features to expose the core module as proper spring modules.

It would be great if I could create a data class in my core module, e.g.

data class FooProperties(
  val bar: String
)

And then in the spring auto-configuration module somehow load it with constructor binding, e.g.

@Configuration
@EnableConfigurationProperties(value = [FooProperties::class], constructorBinding: true)
class FooAutoConfiguration

Or maybe some way to do it programmatically, e.g.

@Bean
fun fooProperties(binder: SpringConstructorBinder, environment: Environment): FooProperties {
  return binder.bind(environment, FooProperties::class)
}

I saw the following issues: #18935, #19011.
If i understand correctly i can create a bean that wraps my data class and the constructor binding will work for my inner class, but it will be nice to have a better way to define the properties without additional wrappers.

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label Sep 3, 2020
@philwebb
Copy link
Member

philwebb commented Sep 3, 2020

Adding an option to @EnableConfigurationProperties is an interesting idea, but I'm a bit worried that there isn't a place to put the prefix that's usually in @ConfigurationProperties. Perhaps a dedicated annotation might be better? Something like:

@RegisterConfigurationPropertiesBean(value=FooProperties.class, prefix="myproperties")

As things stand, I think you can almost do what you want by using the Binder directly. The only problem is that ConfigurationPropertiesBean.getAll won't find them so the /configprops actuator endpoint won't be correct.

@Bean
public FooProperties fooProperties(Environment env) {
    return Binder.get(env).bindOrCreate("my.foo", FooProperties.class);
}

@philwebb philwebb added status: pending-design-work Needs design work before any code can be developed type: enhancement A general enhancement and removed status: waiting-for-triage An issue we've not yet triaged labels Sep 3, 2020
@philwebb philwebb added this to the 2.x milestone Sep 3, 2020
@tomfi
Copy link
Author

tomfi commented Sep 3, 2020

@philwebb - yes a dedicated annotation will be perfect, I forgot about the prefix. It will make it easier to register the data classes with the correct prefix and to enable constructor binding.

I will try the 2nd option as a temporary solution.

Another thing, in case there will be a dedicated annotation, will it also be picked by spring-boot-configuration-processor?
I guess that by creating a bean programmatically it won't be picked by the annotation processor so there won't be auto-complete with spring's plugin in the IDE.

@snicoll
Copy link
Member

snicoll commented Sep 4, 2020

If we'd created a separate annotation we'd have to update the annotation processor to handle it I guess. The contract for third party classes so far was to expose them as a @Bean with a @ConfigurationProperties prefix. My vote is to keep doing that and offering some sort of contract that you can invoke programmatically. A FactoryBean perhaps?

@tomfi
Copy link
Author

tomfi commented Sep 4, 2020

@snicoll the problem comes when you want to use kotlin + data-class + constructor-binding. You can't initialize the class in a Bean unless you use the Binder like @philwebb mentioned.

And in both cases I think the annotation processor won't catch them in order to create the meta-data for the yaml editors which is very important for people like me that create libraries for other developers.

I think there should be an easy way to register immutable data classes (with constructor binding) of 3rd party classes with all the benefits of normal classes (e.g. annotation processing, actuator, etc)

@snicoll
Copy link
Member

snicoll commented Sep 4, 2020

@tomfi I read the issue and I am aware of that, thank you.

@danishsharma1806

This comment has been minimized.

@Davio
Copy link

Davio commented Sep 6, 2020

Maybe I'm not interpreting this correctly, but it looks like @ConfigurationProperties is an annotation that can be put on a @Bean method, but @ConstructorBinding is an annotation which needs to be put on the target type or constructor. For domain model classes which are kept isolated from Spring, this is not ideal.

How about adding a parameter, such as bindingMode to @ConfigurationProperties which defaults to using setters, but can be set to use the constructor.

Example:

@Bean
@ConfigurationProperties(prefix = "my.foo", bindingMode = ConfigurationProperties.BindingMode.CONSTRUCTOR)
public FooProperties fooProperties() {}

@tomfi
Copy link
Author

tomfi commented Sep 6, 2020

@Davio looks nice, but what will you put in the actual method body?

@Bean
@ConfigurationProperties(prefix = "my.foo", bindingMode = ConfigurationProperties.BindingMode.CONSTRUCTOR)
public FooProperties fooProperties() {
    // compilation error, must provide parameters to the constructor
    return new FooProperties();
}

And if you use Binder.get(env).bindOrCreate("my.foo", FooProperties.class);, then the annotation does not do anything.

@Davio
Copy link

Davio commented Sep 6, 2020

Oh of course the method body, it must return an actual instance of the thing.

So if you want to do it without a method body. you basically need the annotation to be processed entirely by the annotation processor and you get the suggested @RegisterConfigurationPropertiesBean(value=FooProperties.class, prefix="myproperties")

And if you go with the existing route, you need to fill in the body, so that's why the factory bean option whas proposed.

I guess a workaround would be to just use the Kotlin no-arg compiler plugin and annotate your data classes with some special custom annotation, like @com.myname.foo.Properties so you can use them as regular property classes from a Java / Spring point of view. It's not ideal, but it would work. It might be tempting to add such an annotation to Spring, but that would make the domain model depend on Spring and we're trying to avoid that.

@philwebb philwebb self-assigned this Sep 10, 2020
@philwebb philwebb modified the milestones: 2.x, 2.4.x Sep 10, 2020
@philwebb
Copy link
Member

philwebb commented Sep 10, 2020

I tried a few different approaches and landed on a new @ImportConfigurationPropertiesBean annotation. The FactoryBean approach unfortunately didn't work out since it needed to be aware of the annotation on the factory method that created it.

The new annotation is conceptually a mix of @Import and @ConfigurationProperties. It's repeatable and you can use it on any @Configuration class. There are still a few things that you can't do if you don't annotate the source class directly. Specifically, you can't have multiple constructors and you can't have default-values. For those cases you'll need a subclass that adds @ConstructorBinding and @DefaultValue annotations.

The original feature request can now be implemented as follows:

data class FooProperties(
  val bar: String
)
@Configuration
@ImportConfigurationPropertiesBean(type = [FooProperties::class], prefix = "foo")
class FooAutoConfiguration

@philwebb philwebb removed the status: pending-design-work Needs design work before any code can be developed label Sep 10, 2020
@philwebb philwebb modified the milestones: 2.4.x, 2.4.0-M3 Sep 10, 2020
@philwebb
Copy link
Member

philwebb commented Sep 10, 2020

Oh, I almost forgot. The annotation processor has also been updated to generate meta-data if it finds @ImportConfigurationPropertiesBean.

@snicoll snicoll added the for: team-attention An issue we'd like other members of the team to review label Sep 10, 2020
@wilkinsona wilkinsona removed the for: team-attention An issue we'd like other members of the team to review label Sep 10, 2020
@tomfi
Copy link
Author

tomfi commented Sep 13, 2020

@philwebb amazing!!

Thank you very much :)

@tomfi
Copy link
Author

tomfi commented Sep 14, 2020

I tried a few different approaches and landed on a new @ImportConfigurationPropertiesBean annotation. The FactoryBean approach unfortunately didn't work out since it needed to be aware of the annotation on the factory method that created it.

The new annotation is conceptually a mix of @Import and @ConfigurationProperties. It's repeatable and you can use it on any @Configuration class. There are still a few things that you can't do if you don't annotate the source class directly. Specifically, you can't have multiple constructors and you can't have default-values. For those cases you'll need a subclass that adds @ConstructorBinding and @DefaultValue annotations.

The original feature request can now be implemented as follows:

data class FooProperties(
  val bar: String
)
@Configuration
@ImportConfigurationPropertiesBean(type = [FooProperties::class], prefix = "foo")
class FooAutoConfiguration

@philwebb you wrote that default values are not supported. If the data class is as the following:

data class FooProperties(
  val a: String,
  val b: String = "default"

Will it honor the default value of the data class and succeed in creating the bean?

@philwebb
Copy link
Member

@tomfi I'm not too sure what bytecode Kotlin with generate for default values. If it's a primary constructor that accepts null then it should bind just fine. One thing I don't think you'll get is the value in your metadata JSON file.

@philwebb philwebb reopened this Sep 14, 2020
@philwebb
Copy link
Member

Reopening to rename the annotation to @ImportAsConfigurationProperties following a team discussion.

@philwebb
Copy link
Member

philwebb commented Sep 15, 2020

I've renamed it to @ConfigurationPropertiesImport for now, we'll need to try it for a bit and see if that feels natural.

@philwebb philwebb added the for: team-attention An issue we'd like other members of the team to review label Sep 15, 2020
@tomfi
Copy link
Author

tomfi commented Sep 15, 2020

@tomfi I'm not too sure what bytecode Kotlin with generate for default values. If it's a primary constructor that accepts null then it should bind just fine. One thing I don't think you'll get is the value in your metadata JSON file.

Yes it is a single constructor, it works perfectly fine with @ConstructorBinding so I guess it will work here as well.

@wilkinsona
Copy link
Member

@philwebb What made you move away from @ImportAsConfigurationProperties that we agreed upon yesterday? To me, @ConfigurationPropertiesImport still suggests that what is being imported is @ConfigurationProperties. I still like the as in the annotation name as it provides a hint that the target of the import isn't already @ConfigurationProperties. I think that's important given the intended usage of the annotation.

@wilkinsona wilkinsona reopened this Sep 15, 2020
@philwebb
Copy link
Member

I was super frustrated yesterday trying to come up with a name for the container annotation, so I wanted to try flipping the terms to see if it made any difference. I agree, it didn't really work :(

We originally were thinking @ImportAsConfigurationProperties/@ImportAllAsConfigurationProperties but @ImportAllAsConfigurationProperties feels really weird, especially as @ImportAsConfigurationProperties already takes an array of types. I'd really like to find a name that doesn't end with s so that we can use the standard convention for the container.

Perhaps we should add Bean back as a suffix?

@ImportAsConfigurationPropertiesBean / @ImportAllAsConfigurationPropertiesBeans

Either that or I should just forget about finding a good name for the container and we go with:

@ImportAsConfigurationProperties / @ImportAllAsConfigurationPropertiesContainer

@philwebb philwebb removed the for: team-attention An issue we'd like other members of the team to review label Sep 15, 2020
@Davio
Copy link

Davio commented Sep 17, 2020

My only remaining question is: if there are multiple constructors, how does it deduce which one to use? The one with the most (matched) arguments or something like that?

@tomfi
Copy link
Author

tomfi commented Sep 17, 2020

@Davio as @philwebb mentioned above:

you can't have multiple constructors and you can't have default-values

So the answer is, this will work only if there is a single constructor - which is perfect, at least for my kotlin data-class use case.

@Davio
Copy link

Davio commented Sep 17, 2020

Okay, for your use case it will work, but if you have a non-data Kotlin class with default parameters, it won't work. Maybe that's an acceptable tradeoff as there might not be a sensible way to determine the 'primary' constructor without it having a special annotation or something like that.

snicoll added a commit that referenced this issue Oct 7, 2020
This commit reverts the support of constructor binding on 3rd party
classes using @ImportConfigurationPropertiesBean

See gh-23172

Closes gh-23593
@snicoll
Copy link
Member

snicoll commented Oct 7, 2020

FTR we've decided to revert this feature as it brings a number of inconsistencies. Please follow-up on #23607

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: enhancement A general enhancement
Projects
None yet
Development

No branches or pull requests

7 participants