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:
20
spring-aop/src/main/java/com/ankurm/aop/AopApplication.java
Normal file
20
spring-aop/src/main/java/com/ankurm/aop/AopApplication.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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> — {@code execution}, {@code within}.
|
||||
* Evaluated statically against the method signature.</li>
|
||||
* <li><strong>Runtime type matching</strong> — {@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> — {@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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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} — note
|
||||
* the plural — 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 — 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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} — in a helper, in a
|
||||
* factory, in a test — 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";
|
||||
}
|
||||
}
|
||||
@@ -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 — 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} — 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";
|
||||
}
|
||||
}
|
||||
12
spring-aop/src/main/java/com/ankurm/aop/service/Audited.java
Normal file
12
spring-aop/src/main/java/com/ankurm/aop/service/Audited.java
Normal 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 {
|
||||
}
|
||||
@@ -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
|
||||
* — 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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
12
spring-aop/src/main/java/com/ankurm/aop/service/Marker.java
Normal file
12
spring-aop/src/main/java/com/ankurm/aop/service/Marker.java
Normal 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 {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
10
spring-aop/src/main/resources/application.yaml
Normal file
10
spring-aop/src/main/resources/application.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
spring:
|
||||
application:
|
||||
name: spring-aop-demo
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
104
spring-aop/src/test/java/com/ankurm/aop/AopContractTests.java
Normal file
104
spring-aop/src/test/java/com/ankurm/aop/AopContractTests.java
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user