Skip to content

Instantly share code, notes, and snippets.

@mickroll
Last active December 1, 2020 08:07
Show Gist options
  • Save mickroll/1310eeed7f34a514461b25a470d434db to your computer and use it in GitHub Desktop.
Save mickroll/1310eeed7f34a514461b25a470d434db to your computer and use it in GitHub Desktop.
build plugin for byte-buddy-maven-plugin, enriches bean classfiles with a default constructor. annotations for identifying bean classes are configurable.
package com.github.mickroll.bytebuddy.plugin;
import static net.bytebuddy.matcher.ElementMatchers.is;
import static net.bytebuddy.matcher.ElementMatchers.isAnnotatedWith;
import static net.bytebuddy.matcher.ElementMatchers.isConstructor;
import static net.bytebuddy.matcher.ElementMatchers.isFinal;
import static net.bytebuddy.matcher.ElementMatchers.isPackagePrivate;
import static net.bytebuddy.matcher.ElementMatchers.isProtected;
import static net.bytebuddy.matcher.ElementMatchers.isPublic;
import static net.bytebuddy.matcher.ElementMatchers.isStatic;
import static net.bytebuddy.matcher.ElementMatchers.not;
import static net.bytebuddy.matcher.ElementMatchers.takesNoArguments;
import java.lang.annotation.Annotation;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.inject.Inject;
import net.bytebuddy.asm.MemberAttributeExtension;
import net.bytebuddy.build.BuildLogger;
import net.bytebuddy.build.Plugin;
import net.bytebuddy.description.NamedElement.WithRuntimeName;
import net.bytebuddy.description.annotation.AnnotationDescription;
import net.bytebuddy.description.annotation.AnnotationList;
import net.bytebuddy.description.field.FieldDescription;
import net.bytebuddy.description.field.FieldList;
import net.bytebuddy.description.method.MethodDescription;
import net.bytebuddy.description.method.MethodDescription.InDefinedShape;
import net.bytebuddy.description.method.MethodList;
import net.bytebuddy.description.modifier.Visibility;
import net.bytebuddy.description.type.TypeDescription;
import net.bytebuddy.dynamic.ClassFileLocator;
import net.bytebuddy.dynamic.DynamicType.Builder;
import net.bytebuddy.implementation.FieldAccessor;
import net.bytebuddy.implementation.Implementation.Composable;
import net.bytebuddy.implementation.MethodCall;
/**
* Enriches bean classes for usage in both jacoco and a cdi container.
* <ol>
* <li>an existing unique constructor will be annotated with {@code @Inject}, if this annotation is missing
* <li>a default constructor will be created, if missing (this is needed by jacoco)
* <li>the new constructor initializes all non-static final fields with default values (null, 0, false, ...)
* </ol>
* This is done to all beans, whose annotation hierarchy contains one of the annotations that were configured for the transformation, for example:
*
* <pre>
&lt;transformation>
&lt;plugin>com.github.mickroll.bytebuddy.plugin.BeanDefaultConstructorPlugin&lt;/plugin>
&lt;arguments>
&lt;argument>
&lt;index>0&lt;/index>
&lt;value>javax.enterprise.context.NormalScope, javax.inject.Scope, javax.interceptor.Interceptor&lt;/value>
&lt;/argument>
&lt;/arguments>
&lt;/transformation>
* </pre>
*
* @author mickroll
*/
public class BeanDefaultConstructorPlugin implements Plugin {
/** Filter for existing constructors that are annotated with {@code @Inject}. */
private static final Junction<MethodDescription.InDefinedShape> EXISTING_ANNOTATED_CONSTRUCTOR = isConstructor().and(isAnnotatedWith(Inject.class));
/** Filter for existing constructors that are candidates for automatic annotation with {@code @Inject}. */
private static final Junction<MethodDescription.InDefinedShape> CONSTRUCTOR_ANNOTATE_CANDIDATE_FILTER =
isConstructor().and(not(isAnnotatedWith(Inject.class)).and(isPublic().or(isProtected()).or(isPackagePrivate())));
/** Filter for existing default constructor. */
private static final Junction<MethodDescription.InDefinedShape> EXISTING_DEFAULT_CONSTRUCTOR =
isConstructor().and(takesNoArguments()).and(isPublic().or(isProtected()).or(isPackagePrivate()));
/**
* Filter for existing default constructor in a super class, that may be called by a new constructor.
* <p>
* A package private constructor may of course only be used by a subclass within the same package!
*/
private static final Junction<MethodDescription.InDefinedShape> EXISTING_SUPERCLASS_DEFAULT_CONSTRUCTOR =
isConstructor().and(takesNoArguments()).and(isPublic().or(isProtected()).or(isPackagePrivate()));
/** Filter for fields that have to be initialized by a new constructor. */
private static final Junction<FieldDescription.InDefinedShape> FIELDS_TO_INITIALIZE = isFinal().and(not(isStatic()));
/** {@code @Inject}-Annotation that should be added to existing constructor. */
private static final Annotation INJECT_CONSTRUCTOR_ANNOTATION = new Inject() {
@Override
public Class<? extends Annotation> annotationType() {
return Inject.class;
}
};
/** Annotation to be added to generated default constructor. */
private static final Annotation GENERATED_CONSTRUCTOR_ANNOTATION = new Deprecated() {
@Override
public Class<? extends Annotation> annotationType() {
return Deprecated.class;
}
@Override
public String since() {
return "";
}
@Override
public boolean forRemoval() {
return false;
}
};
private final BuildLogger logger;
private final List<String> beanAnnotationClassNames;
/**
* Initialize BeanConstructorPlugin.
*
* @param classnames class names of annotations that mark a class as a bean
* @param logger for logging to maven console
*/
public BeanDefaultConstructorPlugin(final String classnames, final BuildLogger logger) {
this.logger = logger;
this.beanAnnotationClassNames = classnames == null ? Collections.emptyList()
: Stream.of(classnames.split(",")).map(String::trim).collect(Collectors.toList());
logger.debug("configured bean annotation classes: " + beanAnnotationClassNames);
}
@Override
public boolean matches(final TypeDescription target) {
return !target.isAnnotation()
&& !target.isEnum()
&& !target.isInterface()
&& isAnnotationPresentInAnnotationHierarchy(target, new HashSet<>());
}
@Override
public void close() {
}
@Override
public Builder<?> apply(final Builder<?> origBuilder, final TypeDescription target, final ClassFileLocator classFileLocator) {
Builder<?> builder = origBuilder;
if (target.getDeclaredMethods().filter(EXISTING_ANNOTATED_CONSTRUCTOR).isEmpty()) {
builder = annotateExistingConstructorWithInject(builder, target);
} else {
logger.debug("found @Inject on bean constructor in: " + target.getName());
}
if (!target.getDeclaredMethods().filter(EXISTING_DEFAULT_CONSTRUCTOR).isEmpty()) {
logger.debug("bean already has a default constructor: " + target.getName());
return builder;
}
return createDefaultConstructor(builder, target);
}
/**
* Create default public constructor, if possible.
*
* @param builder Builder
* @param target target class for new constructor
* @return builder with new constructor
*/
private Builder<?> createDefaultConstructor(final Builder<?> builder, final TypeDescription target) {
// find public or protected default super constructor (0..1)
final Optional<InDefinedShape> superClassDefaultConstructor =
target.getSuperClass().asErasure().getDeclaredMethods().filter(EXISTING_SUPERCLASS_DEFAULT_CONSTRUCTOR).stream().findFirst();
if (superClassDefaultConstructor.isEmpty()) {
logger.error("Bean superclass lacks default constructor. Unable to create super() call. Skipping constructor creation on " + target.getName());
return builder;
}
if (superClassDefaultConstructor.get().isPackagePrivate()) {
final String packageName = target.getPackage().getName();
final String superClassPackageName = target.getSuperClass().asErasure().getPackage().getName();
if (!Objects.equals(packageName, superClassPackageName)) {
logger.error(String.format("Cannot call package private constructor of super class in other package.%nthis: %s%nsuper: %s",
target.getName(), target.getSuperClass().asErasure().getName()));
return builder;
}
}
final FieldList<FieldDescription.InDefinedShape> fieldsToInitialize = target.getDeclaredFields().filter(FIELDS_TO_INITIALIZE);
final String fieldNames = fieldsToInitialize.stream().map(WithRuntimeName::getName).collect(Collectors.joining(", "));
logger.debug(String.format("creating default constructor with final fields [%s] for %s", fieldNames, target.getName()));
Composable code = MethodCall.invoke(superClassDefaultConstructor.get());
for (final FieldDescription.InDefinedShape field : fieldsToInitialize) {
code = code.andThen(FieldAccessor.of(field).setsDefaultValue());
}
return builder.defineConstructor(Visibility.PUBLIC).intercept(code).annotateMethod(GENERATED_CONSTRUCTOR_ANNOTATION);
}
/**
* Add {@code @Inject} - annotation to one existing constructor, if one candidate is found.
*
* @param builder Builder
* @param target target class that contains the constructors
* @return builder with annotated constructor
*/
private Builder<?> annotateExistingConstructorWithInject(final Builder<?> builder, final TypeDescription target) {
final MethodList<MethodDescription.InDefinedShape> annotateCandidates = target.getDeclaredMethods().filter(CONSTRUCTOR_ANNOTATE_CANDIDATE_FILTER);
if (annotateCandidates.isEmpty()) {
return builder;
} else if (annotateCandidates.size() > 1) {
final String candidateNames = annotateCandidates.stream().map(ctor -> ctor.asSignatureToken().toString()).collect(Collectors.joining(",\n"));
logger.error("Skipping automatic @Inject annotation, found multiple matching constructors. Please manually annotate one of:\n" + candidateNames);
return builder;
}
final MethodDescription.InDefinedShape injectConstructorCandidate = annotateCandidates.getOnly();
logger.debug("adding @Inject to bean constructor: " + injectConstructorCandidate);
return builder.visit(new MemberAttributeExtension.ForMethod().annotateMethod(INJECT_CONSTRUCTOR_ANNOTATION).on(is(injectConstructorCandidate)));
}
/**
* Looks for the presence of at least one of the given annotations on a type. Also looks at the annotations on those annotations and so on.
*
* @param type the type
* @param alreadyCheckedTypes types that were checked, for skipping multiple occurrences of the same annotation
* @return {@code true}, if at least one of the given annotations is found on the type or on one of its annotations (recursive on all found annotations)
*/
private boolean isAnnotationPresentInAnnotationHierarchy(final TypeDescription type, final Collection<TypeDescription> alreadyCheckedTypes) {
if (alreadyCheckedTypes.contains(type)) {
return false;
}
alreadyCheckedTypes.add(type);
final AnnotationList annotations = type.getDeclaredAnnotations();
return isBeanAnnotationPresent(annotations)
|| annotations.stream().anyMatch(annotation -> isAnnotationPresentInAnnotationHierarchy(annotation.getAnnotationType(), alreadyCheckedTypes));
}
/**
* Determines, if the given AnnotationList contains any of the bean-identifying annotations.
*
* @param annotations AnnotationList
* @return {@code true}, if bean-identifying annotation was found
*/
private boolean isBeanAnnotationPresent(final AnnotationList annotations) {
return annotations.stream()
.map(AnnotationDescription::getAnnotationType)
.map(TypeDescription::getName)
.anyMatch(beanAnnotationClassNames::contains);
}
}
@famod
Copy link

famod commented Jul 7, 2020

This dependency is needed to compile the plugin:

        <dependency>
            <groupId>net.bytebuddy</groupId>
            <artifactId>byte-buddy-dep</artifactId>
            <version>1.10.13</version>
            <scope>provided</scope>
        </dependency>

@mickroll
Copy link
Author

mickroll commented Jul 8, 2020

Enriches bean classes for usage in both jacoco and a cdi container.

  1. an existing unique constructor will be annotated with @Inject, if this annotation is missing
  2. a default constructor will be created, if missing (this is needed by jacoco)
  3. the new constructor initializes all non-static final fields with default values (null, 0, false, ...)

This is done to all beans, whose annotation hierarchy contains one of the annotations that were configured for the transformation in pom.xml, see example below javax.enterprise.context.NormalScope, javax.inject.Scope, javax.interceptor.Interceptor. Scanned Annotations do not have to be in the classpath of the plugin.

A default constructor is only created, if possible. The base class has to have a default constructor itself, which has to be visible to the given class (public, protected, package-private if in same package).
Normally a CDI container would pick up and use the (newly created) default constructor. To counter this, an existing constructor is annotated with @Inject, so CDI uses the original one.

@mickroll
Copy link
Author

mickroll commented Jul 8, 2020

Plugin code is stable now. Last improvements were:

  • no classpath dependency to annotations that identify a class as bean
  • annotations to scan for are configrable from pom.xml

Use this plugin as follows:

            <build>
                <plugins>
                    <plugin>
                        <groupId>net.bytebuddy</groupId>
                        <artifactId>byte-buddy-maven-plugin</artifactId>
                        <executions>
                            <execution>
                                <id>enforce-bean-default-constructor-presence</id>
                                <phase>process-classes</phase>
                                <goals>
                                    <goal>transform</goal>
                                </goals>
                                <configuration>
                                    <transformations>
                                        <transformation>
                                            <plugin>com.github.mickroll.bytebuddy.plugin.BeanDefaultConstructorPlugin</plugin>
                                            <arguments>
                                                <argument>
                                                    <index>0</index>
                                                    <value>javax.enterprise.context.NormalScope, javax.inject.Scope, javax.interceptor.Interceptor</value>
                                                </argument>
                                            </arguments>
                                        </transformation>
                                    </transformations>
                                </configuration>
                            </execution>
                        </executions>
                    </plugin>
                </plugins>
            </build>

@mickroll
Copy link
Author

@famod
Copy link

famod commented Nov 30, 2020

@mickroll Any chance to update this to the latest state of our internal plugin? Thanks!

@mickroll
Copy link
Author

mickroll commented Dec 1, 2020

@famod Just updated to the latest state. Only a minor change, now plugin skips enums and interfaces.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment