Reputation: 187
I am trying to figure out how to define Pointcuts and how to handle multiple annotations using spring AOP.
I have the following custom annotations:
@RequiresNonBlank
@Retention (RetentionPolicy.RUNTIME)
@Target (value = {ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Documented
public @interface RequiresNonBlank {
String value();
Class<? extends StuffException> throwIfInvalid();
}
and
@RequiresNonBlankDummy
@Retention (RetentionPolicy.RUNTIME)
@Target (value = {ElementType.METHOD})
@Documented
@RequiresNonBlank (
value = "T(com.stuff.exceptions.annotation.TestDummyValueHolder).value",
throwIfInvalid = TestDummyStuffException.class
)
@interface RequiresNonBlankDummy {
}
and I have the following dummy controller:
TestDummyController
@Component
public class TestDummyController {
@RequiresNonBlank (
value = "T(com.stuff.exceptions.annotation.TestDummyValueHolder).value",
throwIfInvalid = TestDummyStuffException.class
)
public boolean methodWithRequiresNonBlankAnnotation() {
return true;
}
@RequiresNonBlankDummy
public boolean methodWithRequiresNonBlankDummyAnnotation() {
return true;
}
@RequiresNonBlankDummy
@RequiresNonBlank (
value = "T(com.stuff.exceptions.annotation.TestDummyValueHolder).anotherValue",
throwIfInvalid = TestDummyStuffException.class
)
public boolean methodWithMultipleRequiresNonBlankAnnotation() {
return true;
}
}
My TestDummyValueHolder
is a class, which only contains two Strings (value
and anotherValue
) and their corresponding getters and setters.
I want to define one or multiple pointcuts (@Pointcut
), which would handle one or multiple / stacked @RequiresNonBlank
on a method ("derived" annotations such as @RequiresNonBlankDummy
should be taken into account).
My aspect handler currently looks like this:
RequiresNonBlankAspect
@Component
@Aspect
@Slf4j
public class RequiresNonBlankAspect {
private static final String REQUIRES_NON_BLANK_FQPN =
"com.stuff.exceptions.annotation.RequiresNonBlank";
@Before("execution(@" + REQUIRES_NON_BLANK_FQPN + " * *(..)) && @annotation(annotation)")
public void evaluatePreconditionItself(JoinPoint joinPoint, RequiresNonBlank annotation) {
evaluatePrecondition(joinPoint, annotation);
}
@Before("execution(@(@" + REQUIRES_NON_BLANK_FQPN + " *) * *(..)) && @annotation(annotation)")
public void evaluatePreconditionOnAnnotation(JoinPoint joinPoint, RequiresNonBlank annotation) {
evaluatePrecondition(joinPoint, annotation);
}
private void evaluatePrecondition(JoinPoint joinPoint, RequiresNonBlank annotation) {
try {
Objects.requireNonNull(annotation);
} catch (NullPointerException e) {
log.error("No annotation found!", e);
}
ExpressionParser elParser = new SpelExpressionParser();
Expression expression = elParser.parseExpression(annotation.value());
String expressionToEvaluate = (String) expression.getValue(joinPoint.getArgs());
log.info("value to check: {}", expressionToEvaluate);
if (StringUtils.isEmpty(expressionToEvaluate)) {
try {
throw annotation.throwIfInvalid().getConstructor().newInstance();
} catch (InstantiationException | IllegalAccessException | InvocationTargetException
| NoSuchMethodException e) {
log.error("Could not throw the exception configured!", e);
}
}
}
}
However: annotationOnAnnotation(...)
does not work. annotationItself
however does.
I have the following Test:
RequiresNonBlankTest
@SpringBootApplication
@ActiveProfiles (profiles = "test")
@RunWith (SpringRunner.class)
public class RequiresNonBlankTest {
@Autowired
private TestDummyController controller;
@Test (expected = TestDummyStuffException.class)
public void testRequiresNonBlank_valueIsNull() {
TestDummyValueHolder.setValue(null);
controller.methodWithRequiresNonBlankAnnotation();
}
@Test
public void testRequiresNonBlank_valueIsNotNull() {
TestDummyValueHolder.setValue("value: non-null");
assertThat(controller.methodWithRequiresNonBlankAnnotation(), equalTo(true));
}
@Test (expected = TestDummyStuffException.class)
public void testRequiresNonBlankDummy_valueIsNull() {
TestDummyValueHolder.setValue(null);
controller.methodWithRequiresNonBlankDummyAnnotation();
}
@Test
public void testRequiresNonBlankDummy_valueIsNotNull() {
TestDummyValueHolder.setValue("value: non-null");
assertThat(controller.methodWithRequiresNonBlankDummyAnnotation(), equalTo(true));
}
}
However: test testRequiresNonBlankDummy_valueIsNull()
fails.
I also would like to know how to not only react to annotations on annotations on a method (see @RequiresNonBlankDummy
), but also to what I have in TestDummyController#methodWithMultipleRequiresNonBlankAnnotation
(stacked / multiple annotations). Is this possible and if so, how?
I am using Spring Boot and Spring AOP. I have tried using AnnotationUtils
and AnnotationElementUtils
, but I at least from what I can tell it did not help. Please help me or give me a hint on how to solve this.
Edit (15.08.2021):
TestDummyValueHolder
public class TestDummyValueHolder {
private static String value;
private static String anotherValue;
public static String getValue() {
return TestDummyValueHolder.value;
}
public static void setValue(String value) {
TestDummyValueHolder.value = value;
}
public static String getAnotherValue() {
return TestDummyValueHolder.anotherValue;
}
public static void setAnotherValue(String anotherValue) {
TestDummyValueHolder.anotherValue = anotherValue;
}
}
StuffException is pretty generic. In fact you could replace it with any Exception, which has a no-args constructor. I have also updated the aspect handler (RequiresNonBlankAspect) to be like what @kriegaex mentioned.
Upvotes: 1
Views: 880
Reputation: 67297
Writing a new answer after the OP updated his question, correcting something that was wrong in the beginning. Now the old answer does not fit anymore.
OK, the remaining problems are:
Now that we changed from chaining two pointcuts with ||
to having two separate pointcuts, the annotation binding works more reliably. Please note, though:
methodWithMultipleRequiresNonBlankAnnotation
which carries both the normal and the meta annotation, both advices are triggered, i.e. something that maybe you only expect to happen once, now actually happens twice.T(de.scrum_master.spring.q68785567.TestDummyValueHolder).anotherValue
(in @RequiresNonBlank
) and T(de.scrum_master.spring.q68785567.TestDummyValueHolder).value
(in the annotation of @RequiresNonBlankDummy
), then this is actually better. It depends on your requirement.Your usage of @annotation(annotation)
in combination with the parameter binding RequiresNonBlank annotation
stops the advice from firing for @RequiresNonBlankDummy
(method methodWithRequiresNonBlankDummyAnnotation
), because the two annotation types are incompatible and there is no such thing as one annotation type extending another or implementing interfaces. So all you are left with in this case is to use the poinctuts without parameter binding and find the annotations via reflection from inside the advice methods.
Update: OK, I made the assumption that in case of both a direct and a meta annotation you want both advices firing. The solution then looks like this:
package de.scrum_master.spring.q68785567;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Objects;
@Component
@Aspect
//@Slf4j
public class RequiresNonBlankAspect {
private static final Logger log = LoggerFactory.getLogger(RequiresNonBlankAspect.class.getName());
private static final String REQUIRES_NON_BLANK_FQPN =
"de.scrum_master.spring.q68785567.RequiresNonBlank";
private ExpressionParser elParser = new SpelExpressionParser();
@Before("execution(@" + REQUIRES_NON_BLANK_FQPN + " * *(..)) && @annotation(requiresNonBlank)")
public void evaluatePreconditionItself(JoinPoint joinPoint, RequiresNonBlank requiresNonBlank) {
log.info("[DIRECT] " + joinPoint + " -> " + requiresNonBlank);
evaluatePrecondition(joinPoint, requiresNonBlank);
}
@Before("execution(@(@" + REQUIRES_NON_BLANK_FQPN + " *) * *(..))")
public void evaluatePreconditionOnAnnotation(JoinPoint joinPoint) {
RequiresNonBlank requiresNonBlank = getRequiresNonBlankMeta(joinPoint);
log.info("[META] " + joinPoint + " -> " + requiresNonBlank);
evaluatePrecondition(joinPoint, requiresNonBlank);
}
private RequiresNonBlank getRequiresNonBlankMeta(JoinPoint joinPoint) {
RequiresNonBlank requiresNonBlank = null;
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
Annotation[] annotations = method.getAnnotations();
for (Annotation annotation : annotations) {
RequiresNonBlank requiresNonBlankMeta = annotation.annotationType().getAnnotation(RequiresNonBlank.class);
if (requiresNonBlankMeta != null) {
requiresNonBlank = requiresNonBlankMeta;
break;
}
}
return requiresNonBlank;
}
public void evaluatePrecondition(JoinPoint joinPoint, RequiresNonBlank requiresNonBlank) {
try {
Objects.requireNonNull(requiresNonBlank);
}
catch (NullPointerException e) {
log.error("No annotation found!", e);
}
Expression expression = elParser.parseExpression(requiresNonBlank.value());
String expressionToEvaluate = (String) expression.getValue(joinPoint.getArgs());
log.info("Evaluated expression: " + expressionToEvaluate);
if (StringUtils.isEmpty(expressionToEvaluate)) {
try {
throw requiresNonBlank.throwIfInvalid().getConstructor().newInstance();
}
catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
log.error("Could not throw the exception configured!", e);
}
}
}
}
Update 2: This gist is my take on the new requirement of repeatable annotations, multiple meta annotations and mixing them, but using on-board Java means instead of Spring annotation utilities in order to also make it work in native AspectJ applications outside of a Spring context. BTW, I renamed some classes and methods because for me your names were too similar and somewhat inexpressive.
Upvotes: 2