1
0

Three new article modules: configuration binding, profiles and config data, Spring AOP

configuration-properties/  @ConfigurationProperties vs @Value on Spring Boot 4.1.1.
  The relaxed-binding matrix is generated by binding each spelling rather than
  transcribed, and re-checked against real processes -- the in-process probe was
  wrong twice before it was right. Records the three findings that came out of it:
  @Value does get relaxed resolution inside Spring Boot (Boot attaches
  ConfigurationPropertySources), the configuration processor silently stops
  generating metadata on JDK 23+ when declared as a plain dependency, and @Valid is
  not what makes nested constraints run.

profiles-and-config/       Precedence, profiles, spring.config.import and config trees.
  /precedence reports every source holding a property in rank order with file and
  line, which turns "my profile file had no effect" into a two-line answer. Also
  pins the counterintuitive one: an imported file outranks the file that imported it.

spring-aop/                Designators, proxy types, and aspects that do not fire.
  One advice per supported designator so the reference table is generated from real
  matches; all fourteen unsupported designators fed to the parser. Two corrections to
  the reference documentation: unsupported designators throw
  UnsupportedPointcutPrimitiveException (extends RuntimeException, not
  IllegalArgumentException), and spring-boot-starter-aop was renamed to
  spring-boot-starter-aspectj in Boot 4.

19 contract tests across the three modules, 15 captured transcripts, all regenerated
by scripts/run-all.sh. Verified on Spring Boot 4.1.1, Spring Framework 7.0.9,
JDK 25.0.4.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gip4srpzMwjgoba6uEfbr5
This commit is contained in:
2026-09-08 16:36:17 +00:00
parent 958b401f0f
commit 86246dc860
107 changed files with 5075 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
package com.ankurm.aop;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Companion application for the ankurm.com article
* "Spring AOP Explained: Pointcuts, Advice Types, and Why Your Aspect Isn't Firing".
*
* <p>Two halves. {@code aspect/} and {@code service/} contain aspects that work, one per
* pointcut designator, so the designator reference in the article is generated from real
* matches. {@code broken/} contains aspects that do not fire, each for a different reason,
* each paired with the fix.
*/
@SpringBootApplication
public class AopApplication {
public static void main(String[] args) {
SpringApplication.run(AopApplication.class, args);
}
}

View File

@@ -0,0 +1,141 @@
package com.ankurm.aop.aspect;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import com.ankurm.aop.service.Audited;
import com.ankurm.aop.service.Marker;
import com.ankurm.aop.service.OrderService;
import com.ankurm.aop.service.Payload;
import org.springframework.stereotype.Component;
/**
* One advice per pointcut designator Spring AOP supports. Ten designators, ten pieces of
* advice, all on the same small set of beans, so the article's reference table can be
* generated from {@code /aop/designators} rather than transcribed.
*
* <p>The designators fall into three groups worth keeping straight:
* <ul>
* <li><strong>Signature matching</strong> &mdash; {@code execution}, {@code within}.
* Evaluated statically against the method signature.</li>
* <li><strong>Runtime type matching</strong> &mdash; {@code this}, {@code target},
* {@code args}, {@code @target}, {@code @args}. These force a runtime check on every
* candidate call, which is why they cost more than they look like they should.</li>
* <li><strong>Annotation and bean matching</strong> &mdash; {@code @within},
* {@code @annotation}, {@code bean}. The last is Spring's own, not AspectJ's.</li>
* </ul>
*/
@Aspect
@Component
public class DesignatorAspect {
private final MatchRecorder recorder;
public DesignatorAspect(MatchRecorder recorder) {
this.recorder = recorder;
}
/**
* A named pointcut, so the same expression is not repeated in six places. Naming
* pointcuts is the single biggest readability win available in Spring AOP.
*/
@Pointcut("within(com.ankurm.aop.service..*)")
public void inServiceLayer() {
}
// -- signature matching ------------------------------------------------------------
/** Matches method executions by signature. The workhorse; everything else is a filter. */
@Before("execution(public String com.ankurm.aop.service.OrderService.place(String, int))")
public void executionByFullSignature(JoinPoint jp) {
record("execution (full signature)", jp);
}
/** The same designator with wildcards, which is how it is normally written. */
@Before("execution(* com.ankurm.aop.service.*Service.cancel(..))")
public void executionWithWildcards(JoinPoint jp) {
record("execution (wildcards)", jp);
}
/** Limits matching to join points inside a type. Static: no runtime test. */
@Before("within(com.ankurm.aop.service.InventoryService)")
public void withinType(JoinPoint jp) {
record("within", jp);
}
// -- runtime type matching ---------------------------------------------------------
/**
* {@code this} tests the PROXY. With a JDK proxy the proxy implements the interface but
* is not an instance of the implementation class, so {@code this(DefaultOrderService)}
* does not match. With a CGLIB proxy it does, because the proxy is a subclass.
* That difference is measured in {@code docs/output/03-proxy-types.txt}.
*/
@Before("this(com.ankurm.aop.service.OrderService) && inServiceLayer()")
public void thisProxyIsOrderService(JoinPoint jp) {
record("this(OrderService)", jp);
}
/** {@code target} tests the object BEHIND the proxy, so the proxy type is irrelevant. */
@Before("target(com.ankurm.aop.service.DefaultOrderService)")
public void targetIsImplementation(JoinPoint jp) {
record("target(DefaultOrderService)", jp);
}
/** Matches on the runtime types of the arguments, and can bind them. */
@Before("args(sku, quantity) && inServiceLayer()")
public void argsByType(JoinPoint jp, String sku, int quantity) {
record("args (bound: " + sku + "/" + quantity + ")", jp);
}
/** The class of the executing object carries the annotation. Runtime test. */
@Before("@target(com.ankurm.aop.service.Audited) && execution(* *.cancel(..))")
public void targetClassAnnotated(JoinPoint jp) {
record("@target(Audited)", jp);
}
/** The runtime types of the arguments carry the annotation. */
@Before("@args(com.ankurm.aop.service.Trackable) && inServiceLayer()")
public void argumentTypesAnnotated(JoinPoint jp) {
record("@args(Trackable)", jp);
}
// -- annotation and bean matching --------------------------------------------------
/** Declaring type carries the annotation. Static, so cheaper than {@code @target}. */
@Before("@within(com.ankurm.aop.service.Audited) && execution(* *.place(..))")
public void declaringTypeAnnotated(JoinPoint jp) {
record("@within(Audited)", jp);
}
/** The method itself carries the annotation. The one most people reach for first. */
@Before("@annotation(com.ankurm.aop.service.Marker)")
public void methodAnnotated(JoinPoint jp) {
record("@annotation(Marker)", jp);
}
/** Spring's own designator: match by bean name, wildcards allowed. Not AspectJ. */
@Before("bean(inventoryService)")
public void byBeanName(JoinPoint jp) {
record("bean(inventoryService)", jp);
}
/** Wildcards work on bean names too, which is the usual reason to use this designator. */
@Before("bean(*OrderService)")
public void byBeanNameWildcard(JoinPoint jp) {
record("bean(*OrderService)", jp);
}
private void record(String designator, JoinPoint jp) {
recorder.record(designator, jp.getSignature().toShortString());
}
/** Referenced by {@code @args} so the argument type is used; keeps the compiler honest. */
@SuppressWarnings("unused")
private static Payload unused(OrderService service, Marker marker, Audited audited) {
return null;
}
}

View File

@@ -0,0 +1,30 @@
package com.ankurm.aop.aspect;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Component;
/**
* Collects which designator matched which join point, so the designator table in the article
* is a record of what actually fired rather than a restatement of the reference documentation.
*/
@Component
public class MatchRecorder {
private final Map<String, Set<String>> matches = new LinkedHashMap<>();
public void record(String designator, String joinPoint) {
matches.computeIfAbsent(designator, key -> new LinkedHashSet<>()).add(joinPoint);
}
public Map<String, Set<String>> matches() {
return matches;
}
public void clear() {
matches.clear();
}
}

View File

@@ -0,0 +1,118 @@
package com.ankurm.aop.broken;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import com.ankurm.aop.aspect.MatchRecorder;
import org.springframework.stereotype.Component;
/**
* The gallery. Each aspect here is written the way it is commonly written and does not fire,
* for a different reason. {@code /aop/broken} exercises them all and reports which ones
* recorded a match, so the article's table of failure modes is generated from real misses.
*
* <p>The working versions live in {@link com.ankurm.aop.aspect.DesignatorAspect}; each entry
* below names the fix.
*/
public final class BrokenAspects {
private BrokenAspects() {
}
/**
* <strong>1. The aspect is not a Spring bean.</strong>
*
* <p>{@code @Aspect} is an AspectJ annotation. It tells Spring how to interpret a bean it
* already has; it does not make the class into a bean. Without {@code @Component} (or an
* {@code @Bean} method, or a component-scan stereotype) the class is never instantiated
* and the pointcut is never registered.
*
* <p>This is the single most common cause, and the most invisible: there is no warning,
* because from Spring's point of view nothing was ever asked for.
*
* <p><strong>Fix:</strong> add {@code @Component}.
*/
@Aspect
public static class NotABean {
@Before("execution(* com.ankurm.aop.service.*.*(..))")
public void neverRuns() {
Recorder.record("1. @Aspect without @Component");
}
}
/**
* <strong>2. The pointcut expression does not match anything.</strong>
*
* <p>A pointcut that matches nothing is indistinguishable at runtime from an aspect that
* was never registered. Here the package is {@code com.ankurm.aop.services} &mdash; note
* the plural &mdash; which does not exist. AspectJ parses it happily: a package name that
* matches no type is not an error, it is an empty match set.
*
* <p><strong>Fix:</strong> check the expression against
* {@code AspectJExpressionPointcut#matches} in a test, or start from
* {@code within(com.example..*)} and narrow.
*/
@Aspect
@Component
public static class PackageTypo {
@Before("execution(* com.ankurm.aop.services.*.*(..))")
public void neverRuns() {
Recorder.record("2. pointcut matches nothing (package typo)");
}
}
/**
* <strong>3. Advising a private method.</strong>
*
* <p>Both proxy strategies work by dispatching through something that wraps the target: a
* JDK proxy implements the interface, a CGLIB proxy extends the class. Neither can
* intercept a private method, because neither can override one. The pointcut is legal and
* simply never matches.
*
* <p><strong>Fix:</strong> make the method at least package-visible and call it from
* outside the object, or move it to a collaborator.
*/
@Aspect
@Component
public static class PrivateMethod {
@Before("execution(private * com.ankurm.aop.service.InventoryService.hidden(..))")
public void neverRuns() {
Recorder.record("3. advice on a private method");
}
}
/**
* <strong>4. Advising a final method.</strong>
*
* <p>Spring Boot proxies with CGLIB by default, which subclasses the target. A final
* method cannot be overridden, so the subclass inherits the original and calls go
* straight to it. No error is raised for a final method &mdash; only a final <em>class</em>
* fails loudly, because then the subclass itself is impossible.
*
* <p><strong>Fix:</strong> remove {@code final}, or proxy by interface instead.
*/
@Aspect
@Component
public static class FinalMethod {
@Before("execution(* com.ankurm.aop.service.InventoryService.finalCheck(..))")
public void neverRuns() {
Recorder.record("4. advice on a final method");
}
}
/** Static hand-off so the broken aspects can report without each taking a constructor. */
static final class Recorder {
private static MatchRecorder delegate;
static void bind(MatchRecorder recorder) {
delegate = recorder;
}
static void record(String what) {
if (delegate != null) {
delegate.record("FIRED " + what, "unexpected");
}
}
}
}

View File

@@ -0,0 +1,22 @@
package com.ankurm.aop.broken;
import com.ankurm.aop.service.Marker;
/**
* <strong>6. The object was never a bean.</strong>
*
* <p>Spring AOP advises beans. An instance created with {@code new} &mdash; in a helper, in a
* factory, in a test &mdash; has no proxy around it and never will, no matter how many
* annotations it carries. The annotation is a request to the container, and the container
* was not involved.
*
* <p><strong>Fix:</strong> get the instance from the container. If it genuinely must be
* constructed by hand and still advised, that is what AspectJ load-time weaving is for.
*/
public class NewedUpService {
@Marker
public String work() {
return "worked";
}
}

View File

@@ -0,0 +1,37 @@
package com.ankurm.aop.broken;
import com.ankurm.aop.service.Marker;
import org.springframework.stereotype.Service;
/**
* <strong>5. Self-invocation.</strong>
*
* <p>The most expensive failure mode in the gallery, because the code looks correct and the
* annotation is right there. {@link #outer()} is called through the proxy, so advice on it
* runs. The call it then makes to {@link #inner()} is a plain {@code this.inner()} on the
* target object &mdash; the proxy is not involved, so advice on {@code inner()} never runs.
*
* <p>This is the same mechanism that makes {@code @Transactional} and {@code @Cacheable}
* silently do nothing on internal calls, which is why it is worth understanding once rather
* than three times.
*
* <p><strong>Fix:</strong> move {@code inner()} to another bean. Failing that, inject the
* bean into itself and call through that reference, or use
* {@code AopContext.currentProxy()} with {@code exposeProxy = true} &mdash; both work and
* both are worse.
*/
@Service
public class SelfInvokingService {
@Marker
public String outer() {
return "outer -> " + inner();
}
/** Advised in principle. Never advised in practice when reached from {@link #outer()}. */
@Marker
public String inner() {
return "inner";
}
}

View File

@@ -0,0 +1,12 @@
package com.ankurm.aop.service;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Type-level annotation, matched by {@code @within(..)} and {@code @target(..)}. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Audited {
}

View File

@@ -0,0 +1,40 @@
package com.ankurm.aop.service;
import org.springframework.stereotype.Service;
/**
* Implements an interface, so by default Spring AOP proxies it with a JDK dynamic proxy
* &mdash; except that Spring Boot flips the global default to class-based proxies. Which
* one you actually get is measured, not assumed: see
* {@code docs/output/03-proxy-types.txt}.
*/
@Service
@Audited
public class DefaultOrderService implements OrderService {
@Override
@Marker
public String place(String sku, int quantity) {
return "placed " + quantity + " x " + sku;
}
@Override
public String cancel(String id) {
return "cancelled " + id;
}
@Override
public String slow() {
try {
Thread.sleep(25);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
return "slept";
}
/** Not on the interface. A JDK proxy cannot intercept this; a CGLIB proxy can. */
public String interfaceless(Payload payload) {
return "handled " + payload.value();
}
}

View File

@@ -0,0 +1,26 @@
package com.ankurm.aop.service;
import org.springframework.stereotype.Service;
/**
* No interface, so this bean can only be proxied by CGLIB, and only its non-final,
* non-private methods can be advised.
*/
@Service
public class InventoryService {
public String reserve(String sku) {
return "reserved " + sku;
}
/** CGLIB proxies by subclassing. A final method cannot be overridden, so it cannot be advised. */
public final String finalCheck(String sku) {
return "checked " + sku;
}
/** Neither can a private one. Included so the article can show both misses in one class. */
@SuppressWarnings("unused")
private String hidden() {
return "hidden";
}
}

View File

@@ -0,0 +1,12 @@
package com.ankurm.aop.service;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Method-level annotation, matched by {@code @annotation(..)}. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Marker {
}

View File

@@ -0,0 +1,8 @@
package com.ankurm.aop.service;
/** Interface, so this bean can be proxied by a JDK dynamic proxy. */
public interface OrderService {
String place(String sku, int quantity);
String cancel(String id);
String slow();
}

View File

@@ -0,0 +1,6 @@
package com.ankurm.aop.service;
/** Argument type carrying a type annotation, so {@code @args(..)} has something to match. */
@Trackable
public record Payload(String value) {
}

View File

@@ -0,0 +1,12 @@
package com.ankurm.aop.service;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Parameter-type annotation, matched by {@code @args(..)}. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Trackable {
}

View File

@@ -0,0 +1,98 @@
package com.ankurm.aop.web;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.ankurm.aop.aspect.MatchRecorder;
import com.ankurm.aop.service.DefaultOrderService;
import com.ankurm.aop.service.InventoryService;
import com.ankurm.aop.service.OrderService;
import com.ankurm.aop.service.Payload;
import org.springframework.aop.framework.Advised;
import org.springframework.aop.support.AopUtils;
import org.springframework.context.ApplicationContext;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Prints what the container actually built, which is the fastest way to answer "why isn't my
* aspect firing".
*
* <p>Almost every non-firing aspect is one of three things, and all three are visible here:
* the bean is not proxied at all, the bean is proxied by the wrong kind of proxy, or the
* advisor list attached to the proxy does not contain your advice. Guessing between them
* wastes an afternoon; reading them takes a second.
*
* <p>Documented in {@code docs/06-diagnosing-a-silent-aspect.md}. Delete before shipping.
*/
@RestController
public class AopDiagnosticsEndpoint {
private final ApplicationContext context;
private final OrderService orderService;
private final InventoryService inventoryService;
private final MatchRecorder recorder;
public AopDiagnosticsEndpoint(ApplicationContext context, OrderService orderService,
InventoryService inventoryService, MatchRecorder recorder) {
this.context = context;
this.orderService = orderService;
this.inventoryService = inventoryService;
this.recorder = recorder;
}
/** For each interesting bean: is it a proxy, which kind, and what advice is attached. */
@GetMapping("/aop/proxies")
public List<Map<String, Object>> proxies() {
List<Map<String, Object>> rows = new ArrayList<>();
for (String name : List.of("defaultOrderService", "inventoryService",
"selfInvokingService", "newedUpService")) {
if (!context.containsBean(name)) {
continue;
}
Object bean = context.getBean(name);
Map<String, Object> row = new LinkedHashMap<>();
row.put("bean", name);
row.put("class", bean.getClass().getName());
row.put("isProxy", AopUtils.isAopProxy(bean));
row.put("proxyKind", AopUtils.isJdkDynamicProxy(bean) ? "JDK dynamic proxy"
: AopUtils.isCglibProxy(bean) ? "CGLIB subclass" : "not proxied");
row.put("targetClass", AopUtils.getTargetClass(bean).getName());
if (bean instanceof Advised advised) {
row.put("advisorCount", advised.getAdvisors().length);
row.put("advisors", java.util.Arrays.stream(advised.getAdvisors())
.map(a -> a.getAdvice().getClass().getSimpleName()).toList());
row.put("proxiedInterfaces", java.util.Arrays.stream(advised.getProxiedInterfaces())
.map(Class::getSimpleName).toList());
}
rows.add(row);
}
return rows;
}
/** Exercise every advised method, then report which designator matched what. */
@GetMapping("/aop/designators")
public Map<String, Object> designators() {
recorder.clear();
orderService.place("SKU-1", 2);
orderService.cancel("ORD-9");
inventoryService.reserve("SKU-1");
inventoryService.finalCheck("SKU-1");
if (orderService instanceof DefaultOrderService concrete) {
concrete.interfaceless(new Payload("p"));
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("orderServiceProxyKind",
AopUtils.isJdkDynamicProxy(orderService) ? "JDK dynamic proxy"
: AopUtils.isCglibProxy(orderService) ? "CGLIB subclass" : "not proxied");
result.put("orderServiceIsDefaultOrderServiceInstance",
orderService instanceof DefaultOrderService);
result.put("matches", recorder.matches());
return result;
}
}

View File

@@ -0,0 +1,90 @@
package com.ankurm.aop.web;
import java.util.LinkedHashMap;
import java.util.Map;
import com.ankurm.aop.aspect.MatchRecorder;
import com.ankurm.aop.broken.NewedUpService;
import com.ankurm.aop.broken.SelfInvokingService;
import com.ankurm.aop.service.InventoryService;
import org.springframework.aop.support.AopUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Exercises every entry in the broken-aspect gallery and reports whether its advice fired.
*
* <p>Every row should read {@code advice fired: false}. A row that reads {@code true} means
* the failure mode it documents no longer exists in this Spring version, which would be worth
* knowing about.
*/
@RestController
public class BrokenGalleryEndpoint {
private final SelfInvokingService selfInvoking;
private final InventoryService inventory;
private final MatchRecorder recorder;
public BrokenGalleryEndpoint(SelfInvokingService selfInvoking, InventoryService inventory,
MatchRecorder recorder) {
this.selfInvoking = selfInvoking;
this.inventory = inventory;
this.recorder = recorder;
}
@GetMapping("/aop/broken")
public Map<String, Object> broken() {
Map<String, Object> result = new LinkedHashMap<>();
// 5. Self-invocation. outer() is advised; the inner() it calls is not.
recorder.clear();
String viaOuter = selfInvoking.outer();
boolean innerAdvisedViaOuter = recorder.matches().values().stream()
.flatMap(java.util.Set::stream).anyMatch(s -> s.contains("inner"));
// The same method called directly through the proxy IS advised -- proof that the
// method is advisable and only the call path was the problem.
recorder.clear();
selfInvoking.inner();
boolean innerAdvisedDirectly = recorder.matches().values().stream()
.flatMap(java.util.Set::stream).anyMatch(s -> s.contains("inner"));
Map<String, Object> selfInvocation = new LinkedHashMap<>();
selfInvocation.put("beanIsProxied", AopUtils.isAopProxy(selfInvoking));
selfInvocation.put("result", viaOuter);
selfInvocation.put("innerAdvisedWhenCalledFromOuter", innerAdvisedViaOuter);
selfInvocation.put("innerAdvisedWhenCalledDirectly", innerAdvisedDirectly);
result.put("5-self-invocation", selfInvocation);
// 6. An instance created with new is not a bean and is not proxied.
NewedUpService newed = new NewedUpService();
recorder.clear();
newed.work();
Map<String, Object> newedUp = new LinkedHashMap<>();
newedUp.put("isProxy", AopUtils.isAopProxy(newed));
newedUp.put("adviceFired", !recorder.matches().isEmpty());
result.put("6-created-with-new", newedUp);
// 3 and 4. Private and final methods on a proxied bean.
recorder.clear();
inventory.finalCheck("SKU-1");
Map<String, Object> finalMethod = new LinkedHashMap<>();
finalMethod.put("beanIsProxied", AopUtils.isAopProxy(inventory));
finalMethod.put("proxyKind", AopUtils.isCglibProxy(inventory) ? "CGLIB subclass" : "other");
finalMethod.put("adviceFired", !recorder.matches().isEmpty());
result.put("4-final-method", finalMethod);
// 1 and 2 cannot fire by construction; report that nothing recorded a FIRED marker.
result.put("1-aspect-without-component-fired", recorder.matches().keySet().stream()
.anyMatch(k -> k.startsWith("FIRED 1")));
result.put("2-pointcut-typo-fired", recorder.matches().keySet().stream()
.anyMatch(k -> k.startsWith("FIRED 2")));
result.put("3-private-method-fired", recorder.matches().keySet().stream()
.anyMatch(k -> k.startsWith("FIRED 3")));
result.put("note", "every value above should be false except the two that prove the "
+ "method is advisable when reached through the proxy");
return result;
}
}

View File

@@ -0,0 +1,83 @@
package com.ankurm.aop.web;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.ankurm.aop.service.DefaultOrderService;
import org.springframework.aop.aspectj.AspectJExpressionPointcut;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Asks the pointcut parser directly which designators it accepts, and what it says when it
* refuses one.
*
* <p>The list of unsupported designators is documented, but the error you get is not, and the
* error is what you will actually be looking at. Being able to recognise it saves the ten
* minutes it otherwise takes to work out that {@code call()} is an AspectJ designator Spring
* AOP has never supported.
*/
@RestController
public class PointcutParserEndpoint {
/** Designators the reference documentation lists as unsupported in Spring AOP. */
private static final List<String> UNSUPPORTED = List.of(
"call(* com.ankurm.aop.service.OrderService.place(..))",
"get(* com.ankurm.aop.service.*.*)",
"set(* com.ankurm.aop.service.*.*)",
"initialization(com.ankurm.aop.service.*.new(..))",
"staticinitialization(com.ankurm.aop.service.*)",
"preinitialization(com.ankurm.aop.service.*.new(..))",
"handler(java.lang.Exception)",
"adviceexecution()",
"withincode(* com.ankurm.aop.service.*.*(..))",
"cflow(execution(* com.ankurm.aop.service.*.*(..)))",
"cflowbelow(execution(* com.ankurm.aop.service.*.*(..)))",
"if()",
"@this(com.ankurm.aop.service.Audited)",
"@withincode(com.ankurm.aop.service.Marker)");
private static final List<String> SUPPORTED = List.of(
"execution(* com.ankurm.aop.service.OrderService.place(..))",
"within(com.ankurm.aop.service..*)",
"this(com.ankurm.aop.service.OrderService)",
"target(com.ankurm.aop.service.DefaultOrderService)",
"args(String, int)",
"@target(com.ankurm.aop.service.Audited)",
"@args(com.ankurm.aop.service.Trackable)",
"@within(com.ankurm.aop.service.Audited)",
"@annotation(com.ankurm.aop.service.Marker)",
"bean(defaultOrderService)");
@GetMapping("/aop/parser")
public Map<String, Object> parser() throws Exception {
Map<String, Object> result = new LinkedHashMap<>();
result.put("supported", SUPPORTED.stream().map(this::probe).toList());
result.put("unsupported", UNSUPPORTED.stream().map(this::probe).toList());
return result;
}
/** Parse and evaluate one expression, reporting what the parser did with it. */
private Map<String, Object> probe(String expression) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("expression", expression);
try {
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression(expression);
// Force evaluation: setExpression only stores the string, so an expression is not
// rejected until something asks it to match.
pointcut.matches(DefaultOrderService.class.getMethod("place", String.class, int.class),
DefaultOrderService.class);
row.put("accepted", true);
} catch (Exception ex) {
row.put("accepted", false);
row.put("exception", ex.getClass().getName());
String message = ex.getMessage();
row.put("message", message == null ? null
: message.length() > 220 ? message.substring(0, 220) + "..." : message);
}
return row;
}
}

View File

@@ -0,0 +1,10 @@
spring:
application:
name: spring-aop-demo
server:
port: 8080
logging:
level:
root: WARN

View File

@@ -0,0 +1,104 @@
package com.ankurm.aop;
import com.ankurm.aop.aspect.MatchRecorder;
import com.ankurm.aop.broken.NewedUpService;
import com.ankurm.aop.broken.SelfInvokingService;
import com.ankurm.aop.service.DefaultOrderService;
import com.ankurm.aop.service.InventoryService;
import com.ankurm.aop.service.OrderService;
import org.aspectj.weaver.tools.UnsupportedPointcutPrimitiveException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.aop.aspectj.AspectJExpressionPointcut;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/** Pins the claims the AOP article makes about proxies, designators and failure modes. */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class AopContractTests {
@Autowired OrderService orderService;
@Autowired InventoryService inventoryService;
@Autowired SelfInvokingService selfInvoking;
@Autowired MatchRecorder recorder;
@Test
@DisplayName("Spring Boot proxies with CGLIB even when the bean implements an interface")
void bootDefaultsToCglib() {
assertThat(AopUtils.isCglibProxy(orderService)).isTrue();
assertThat(orderService).isInstanceOf(DefaultOrderService.class);
}
@Test
@DisplayName("self-invocation: the inner call is not advised, the direct call is")
void selfInvocationSkipsAdvice() {
recorder.clear();
selfInvoking.outer();
boolean viaOuter = recorder.matches().values().stream()
.flatMap(java.util.Set::stream).anyMatch(s -> s.contains("inner"));
recorder.clear();
selfInvoking.inner();
boolean direct = recorder.matches().values().stream()
.flatMap(java.util.Set::stream).anyMatch(s -> s.contains("inner"));
assertThat(viaOuter).as("reached through this.inner() -- proxy not involved").isFalse();
assertThat(direct).as("reached through the proxy -- advice runs").isTrue();
}
@Test
@DisplayName("an object created with new is never advised")
void newedUpObjectIsNotAdvised() {
NewedUpService service = new NewedUpService();
recorder.clear();
service.work();
assertThat(AopUtils.isAopProxy(service)).isFalse();
assertThat(recorder.matches()).isEmpty();
}
@Test
@DisplayName("a final method on a proxied bean is not advised, and no error is raised")
void finalMethodIsNotAdvised() {
assertThat(AopUtils.isAopProxy(inventoryService)).isTrue();
recorder.clear();
inventoryService.finalCheck("SKU-1");
assertThat(recorder.matches()).isEmpty();
}
/**
* The reference documentation states that an unsupported designator produces an
* {@code IllegalArgumentException}. It does not. Catching that type will not catch this.
*/
@Test
@DisplayName("unsupported designators throw UnsupportedPointcutPrimitiveException, not IAE")
void unsupportedDesignatorExceptionType() throws Exception {
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression("call(* com.ankurm.aop.service.OrderService.place(..))");
assertThatExceptionOfType(UnsupportedPointcutPrimitiveException.class)
.isThrownBy(() -> pointcut.matches(
DefaultOrderService.class.getMethod("place", String.class, int.class),
DefaultOrderService.class))
.withMessageContaining("unsupported pointcut primitive 'call'");
assertThat(UnsupportedPointcutPrimitiveException.class)
.as("it extends RuntimeException directly, not IllegalArgumentException")
.hasSuperclass(RuntimeException.class);
}
@Test
@DisplayName("a pointcut naming a package that does not exist parses and matches nothing")
void pointcutTypoIsSilent() throws Exception {
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression("execution(* com.ankurm.aop.services.*.*(..))"); // plural
assertThat(pointcut.matches(
DefaultOrderService.class.getMethod("place", String.class, int.class),
DefaultOrderService.class)).isFalse();
}
}