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

Minimise need to iterate over a class's methods #22420

Closed
Tracked by #22560
wilkinsona opened this issue Feb 15, 2019 · 20 comments
Closed
Tracked by #22560

Minimise need to iterate over a class's methods #22420

wilkinsona opened this issue Feb 15, 2019 · 20 comments
Assignees
Labels
in: core Issues in core modules (aop, beans, core, context, expression) type: enhancement A general enhancement
Milestone

Comments

@wilkinsona
Copy link
Member

There are a number of places where Framework iterates over a class's methods. This issue is intended to identify the places where the iteration is unnecessary and can be eliminated. ReflectionUtils keeps a per-Class cache of declared methods. To provide any significant benefit it will be necessary to eliminate all calls to ReflectionUtils for a particular class. It's not yet known if this is possible.

AbstractAutowireCapableBeanFactory.determineTargetType uses the bean definition's targetType to short-circuit type determination. When a bean is defined by ConfigurationClassBeanDefinitionReader processing a @Bean method, this type information is available but it is not used. If the metadata for the @Bean method is StandardMethodMetadata, the target type of the definition is available from the introspected method's return type. Otherwise, the name of the target type is available from MethodMetadata.getReturnTypeName(). It appears to be possible for this type name to be stored in the definition and then be used to load the named class when getTargetType() is invoked.

When processing a ConfigurationClassBeanDefinition, ConstructorResolver.instantiateUsingFactoryMethod calls ReflectionUtils.getAllDeclaredMethods to find the Method for the @Bean method for which the bean definition was created. When the definition was created from StandardMethodMetadata, I think this method will already have been available and could, perhaps, have been stored in the definition's resolvedConstructorOrFactoryMethod field. For ASM-based metadata, the method's name is available and, with some changes to the metadata, it looks like its parameter types could be too. This may then allow the method to be identified directly without the need to iterate over all methods.

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Feb 15, 2019
@jhoeller jhoeller self-assigned this Feb 15, 2019
@jhoeller jhoeller added type: enhancement A general enhancement and removed status: waiting-for-triage An issue we've not yet triaged or decided on labels Feb 15, 2019
@jhoeller jhoeller added this to the 5.2 M1 milestone Feb 15, 2019
@wilkinsona
Copy link
Member Author

wilkinsona commented Feb 15, 2019

With some hacks in place to avoid the two occurrences described above, and with @Configuration class proxying disabled, we reach code that's iterating over the methods looking for annotations:

  • CommonAnnotationBeanPostProcessor(and its super-class InitDestroyAnnotationBeanPostProcessor)
  • AutowiredAnnotationBeanPostProcessor for honouring @Autowired
  • AutowiredAnnotationBeanPostProcessor again when looking for @Lookup methods while determining constructors from bean post-processors
  • Boot's own ConfigurationPropertiesBindingPostProcessor finding the bean's factory method to look for @ConfigurationProperties annotation

After these we move on to proxy creation in InfrastructureAdvisorAutoProxyCreator. There's a single advisor in the case I'm currently looking at, namely BeanFactoryTransactionAttributeSourceAdvisor. This results in iteration over all of the class's methods, looking for one to which the advice can apply. In this case it's looking for @Transactional so it's an annotation search again, but not as easy to avoid with an index due to the use of method-based matching.

Last of all (in the case I'm currently looking at) we get to EventListenerMethodProcessor looking for @EventListener methods. I'm cautiously optimistic that, with some changes in place to avoid the two occurrences described above and an annotation index, we could avoid all reflection over the methods of certain beans. The ability to opt out of configuration class proxying would increase the number of beans that falls into that category.

@wilkinsona
Copy link
Member Author

Somewhat related to this, I think there is some benefit in making the factory method that's used to create something defined using @Bean more readily available.

Boot supports the use of @ConfigurationProperties on a @Bean method, predominantly to support binding to third-party classes when annotating the class itself with @ConfigurationProperties isn't possible. Boot has a class, ConfigurationBeanFactoryMetadata, that performs factory method discovery and caches its results. It would be more robust if the factory Method was directly available via the bean definition rather than having to duplicate the reflective search over the factory class's methods to find the factory method.

Please let me know if you'd prefer for this piece to be tracked as a separate issue.

@sbrannen sbrannen added in: core Issues in core modules (aop, beans, core, context, expression) type: enhancement A general enhancement and removed type: enhancement A general enhancement labels Mar 5, 2019
@jhoeller
Copy link
Contributor

jhoeller commented Mar 11, 2019

I'm about to commit an initial revision: a mechanism for bypassing method traversal for annotation introspection if possible. The isCandidateClass mechanism is consistently used for a bypass check before method traversal attempts now. While by default this is only bypassing standard java types (which never have annotations that we consider searchable), the same mechanism can be used with index metadata which indicates non-presence of certain annotations. All it takes is a more sophisticated implementation of AnnotationUtils.isCandidateClass(clazz, annotationName) which indicates non-presence of a specific annotation in a class (and its superclasses/interfaces); we'll revise that as part of our indexer efforts, ideally through a programmatic registration mechanism populated from index metadata on startup.

As for factory method resolution, I'll tackle this in a separate revision. An initial prototype works reasonably well. We just need to handle the rare cases of @Bean method overloads properly where we still need to apply our construction resolution algorithm against all candidate factory methods.

jhoeller added a commit that referenced this issue Mar 11, 2019
The isCandidateClass mechanism is consistently used for a bypass check before method traversal attempts. While by default this is only bypassing standard java types, the same mechanism can be used with index metadata which indicates non-presence of certain annotations.

See gh-22420
@jhoeller
Copy link
Contributor

jhoeller commented Mar 11, 2019

FWIW, that remaining part is only really trying to avoid full method traversal for pre-identified @Configuration classes... where the methods might have been traversed already, e.g. for finding @Bean methods with standard annotation metadata to begin with. It's worth addressing for the component scan case where the initial @Bean metadata comes from ASM but probably won't make much difference for Class-registered beans where the declared methods cache is populated already.

@jhoeller
Copy link
Contributor

I'm about to wrap up the factory method part: Aside from some general optimizations (like consistently ignoring java.lang.Object methods), we're preserving an existing factory Method handle as far as possible. For the classpath scanning case where we only have ASM metadata initially, we're resolving the Method once... either on type matching or on actual construction, never twice. This can be bypassed by calling the new setResolvedFactoryMethod method on RootBeanDefinition after the initial scan.

FWIW, for the common ASM case, it's hard to avoid full reflective initialization of the affected Class since even a direct getDeclaredMethod(name, paramTypes) call would initialize all declared methods underneath the covers. It doesn't seem too bad to traverse the declared methods once there ourselves since it's at least dealing with a class where we actually know that there are @Bean methods on it.

@wilkinsona
Copy link
Member Author

The change made here seems to have affected some error handling such that a BeanNotOfRequiredTypeException is no longer thrown. Here's an example that passes with Framework 5.1.4 (and earlier 5.2.0 snapshots) but fails with the latest 5.2.0 snapshots:

package com.example.demo;

import static org.hamcrest.Matchers.instanceOf;
import static org.junit.Assert.fail;

import org.junit.Assert;
import org.junit.Test;
import org.springframework.beans.factory.BeanNotOfRequiredTypeException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;

public class BeanNotOfRequiredTypeTests {

	@Test
	public void exceptionThrownWithBeanNotOfRequiredTypeRootCause() {
		try {
			new AnnotationConfigApplicationContext(JdkProxyConfiguration.class).refresh();
			fail();
		} catch (Throwable ex) {
			while (ex.getCause() != null) {
				ex = ex.getCause();
			}
			Assert.assertThat(ex, instanceOf(BeanNotOfRequiredTypeException.class));
		}
	}

	@Configuration
	@EnableAsync
	@Import(UserConfiguration.class)
	static class JdkProxyConfiguration {

		@Bean
		public AsyncBean asyncBean() {
			return new AsyncBean();
		}

	}

	@Configuration
	static class UserConfiguration {

		@Bean
		public AsyncBeanUser user(AsyncBean bean) {
			return new AsyncBeanUser(bean);
		}

	}

	static class AsyncBean implements SomeInterface {

		@Async
		public void foo() {

		}

		@Override
		public void bar() {

		}

	}

	interface SomeInterface {

		void bar();

	}

	static class AsyncBeanUser {

		AsyncBeanUser(AsyncBean asyncBean) {
		}

	}

}

The exception that's thrown with the latest 5.2.0 snapshots is the following:

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'user' defined in com.example.demo.BeanNotOfRequiredTypeTests$UserConfiguration: Unexpected exception during bean creation; nested exception is java.lang.IllegalStateException: Unresolved factory method arguments
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:528)
	at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:320)
	at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:222)
	at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:318)
	at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:846)
	at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:877)
	at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:549)
	at org.springframework.context.annotation.AnnotationConfigApplicationContext.<init>(AnnotationConfigApplicationContext.java:89)
	at com.example.demo.BeanNotOfRequiredTypeTests.exceptionThrownWithBeanNotOfRequiredTypeRootCause(BeanNotOfRequiredTypeTests.java:21)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
	at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
	at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57)
	at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
	at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
	at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
	at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
	at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
	at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
	at org.eclipse.jdt.internal.junit4.runner.JUnit4TestReference.run(JUnit4TestReference.java:89)
	at org.eclipse.jdt.internal.junit.runner.TestExecution.run(TestExecution.java:41)
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:541)
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:763)
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.run(RemoteTestRunner.java:463)
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main(RemoteTestRunner.java:209)
Caused by: java.lang.IllegalStateException: Unresolved factory method arguments
	at org.springframework.util.Assert.state(Assert.java:73)
	at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:618)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1325)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1164)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:555)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:515)
	... 32 more

The exception that's thrown with 5.1.4 is the following:

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'user' defined in com.example.demo.BeanNotOfRequiredTypeTests$UserConfiguration: Unsatisfied dependency expressed through method 'user' parameter 0; nested exception is org.springframework.beans.factory.BeanNotOfRequiredTypeException: Bean named 'asyncBean' is expected to be of type 'com.example.demo.BeanNotOfRequiredTypeTests$AsyncBean' but was actually of type 'com.example.demo.$Proxy18'
	at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:769)
	at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:509)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1305)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1144)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:555)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:515)
	at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:320)
	at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:222)
	at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:318)
	at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:849)
	at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:877)
	at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:549)
	at org.springframework.context.annotation.AnnotationConfigApplicationContext.<init>(AnnotationConfigApplicationContext.java:88)
	at com.example.demo.BeanNotOfRequiredTypeTests.exceptionThrownWithBeanNotOfRequiredTypeRootCause(BeanNotOfRequiredTypeTests.java:21)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
	at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
	at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57)
	at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
	at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
	at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
	at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
	at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
	at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
	at org.eclipse.jdt.internal.junit4.runner.JUnit4TestReference.run(JUnit4TestReference.java:89)
	at org.eclipse.jdt.internal.junit.runner.TestExecution.run(TestExecution.java:41)
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:541)
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:763)
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.run(RemoteTestRunner.java:463)
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main(RemoteTestRunner.java:209)
Caused by: org.springframework.beans.factory.BeanNotOfRequiredTypeException: Bean named 'asyncBean' is expected to be of type 'com.example.demo.BeanNotOfRequiredTypeTests$AsyncBean' but was actually of type 'com.example.demo.$Proxy18'
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1257)
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1167)
	at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:857)
	at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:760)
	... 37 more

I think 5.1.4's behaviour is more helpful to the user. We have failure analysis in Boot for BeanNotOfRequiredTypeException that's now less useful. I found the change in behaviour thanks to this test in Boot upon which the recreation above is based.

@jhoeller
Copy link
Contributor

Hmm I saw a variant of this in some of our tests initially but thought I had fixed it for good. I'll have a look right now.

@jhoeller
Copy link
Contributor

I've pushed a revision which should fix this. Turned out to be as simple as going into our proper exception handling code path even when the arguments remain unresolved for a pre-determined factory method, not just when the factory method itself remains unresolved.

@wilkinsona
Copy link
Member Author

Thanks. I'm seeing the same behaviour as 5.1.4 again now.

@wilkinsona
Copy link
Member Author

wilkinsona commented Mar 13, 2019

The changes here haven't gone quite far enough to allow me to address this scenario from the list above:

Boot's own ConfigurationPropertiesBindingPostProcessor finding the bean's factory method to look for @ConfigurationProperties annotation

I have a bean with ASM-based metadata that's been instantiated by ConstructorResolver. In doing so, it has set resolvedConstructorOrFactoryMethod on the definition. It's package-private so I can't get at its value and factoryMethodToIntrospect remains null. As a result, we have to fall back to iterating over the methods of the bean's factory class to find the @Bean method and determine if it's annotated with @ConfigurationProperties.

Would it be possible to somehow expose resolvedConstructorOrFactoryMethod via public API? Or, to describe the problem without suggesting a solution, would it be possible for Framework to provide an API that, when given a bean name or a bean definition, returns the @Bean method that created the bean or null if it was created via a different mechanism.

@jhoeller
Copy link
Contributor

Hmm we have RootBeanDefinition.getResolvedFactoryMethod() already... Maybe we should just make sure that it is consistently populated, i.e. if resolvedConstructorOrFactoryMethod is set to a method, the same method should be exposed through getResolvedFactoryMethod().

@wilkinsona
Copy link
Member Author

I wasn't sure if resolvedConstructorOrFactoryMethod and factoryMethodToIntrospect may be subtly different. For the specific purpose of finding the @Bean method with @ConfigurationProperties on it, there's no differences as far as I can tell so it would work nicely in this case at least.

@jhoeller
Copy link
Contributor

On review, I can't find a code path where resolvedConstructorOrFactoryMethod is set but factoryMethodToIntrospect is not. Could you step through that particular construction step to find out how resolvedConstructorOrFactoryMethod is being set?

@wilkinsona
Copy link
Member Author

Sorry, it looks like there's some ordering in play here.

Looking in the debugger, resolvedConstructorOrFactoryMethod is set here:

By that point, factoryMethodToIntrospect was also non-null.

We have a BFPP at the moment and when it's called beans with standard annotation metadata have factoryMethodToIntrospect set but the resolvedConstructorOrFactoryMethod as that resolution hasn't taken place.

It looks like we need to move away from using a BFPP. I think that's fine as I can't see too much value in it as we only need the information that it provides once the beans have been created and we can get that in a more straightforward manner now I believe.

@wilkinsona
Copy link
Member Author

Thanks for bearing with me. I think I've found the source of my confusion.

When the bean is being created, the bean definition has been merged and is not the same as the definition that's returned when asking the bean factory for a definition by name. As a result, the factory method information is not available when examining the definition that's returned by the bean factory.

@jhoeller
Copy link
Contributor

Merging preserves the factoryMethodToIntrospect, so should carry over a Method set early. As for programmatically introspecting it, for guaranteed access to the factory's resolution efforts, you'd have to call ConfigurableBeanFactory.getMergedBeanDefinition... at a late enough point.

Or are we suffering from early merging here where the merge result is not preserved since the configuration isn't frozen yet?

@wilkinsona
Copy link
Member Author

Merging preserves the factoryMethodToIntrospect, so should carry over a Method set early.

In the case I'm looking at, it's being set after the merging. Specifically, it's happening here:

you'd have to call ConfigurableBeanFactory.getMergedBeanDefinition at a late enough point.

That's what I needed. Thanks. I've reworked the original code so that it's no longer a BFPP and we now just look up the information on demand. This means that the earliest that it will happen is in bean post-processing by which time the bean has been instantiated and the merged bean definition has the outcome of the resolution efforts.

@sbrannen
Copy link
Member

FYI: the discussions around finding annotations on @Bean methods are directly related to #22541.

@mdeinum, you may want to follow along here.

@sbrannen
Copy link
Member

Boot supports the use of @ConfigurationProperties on a @Bean method, predominantly to support binding to third-party classes when annotating the class itself with @ConfigurationProperties isn't possible. Boot has a class, ConfigurationBeanFactoryMetadata, that performs factory method discovery and caches its results.

ConfigurationBeanFactoryMetadata doesn't properly track overloaded @Bean methods within a single @Configuration class -- right?

Not that people often overload their @Bean methods, but it is supported, and at a glance it looks like ConfigurationBeanFactoryMetadata only considers the method names, ignoring the parameter lists.

@wilkinsona
Copy link
Member Author

Things have changed quite a bit in 2.2. We're now using the resolved factory method that's cached on the definition so overriding should not cause any problems. That said, please don't point people to ConfigurationBeanFactoryMetadata. While it's currently public we would prefer that it, or at least as much as possible of it, is not.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: core Issues in core modules (aop, beans, core, context, expression) type: enhancement A general enhancement
Projects
None yet
Development

No branches or pull requests

4 participants