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:
67
spring-aop/README.md
Normal file
67
spring-aop/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Spring AOP: designators, proxies, and aspects that do not fire
|
||||
|
||||
Companion project for [**Spring AOP Explained**](https://ankurm.com/) on ankurm.com.
|
||||
|
||||
Two halves. One set of aspects that work — one advice per pointcut designator, so the reference
|
||||
table is generated from real matches. One set that does not fire, each for a different reason,
|
||||
each paired with its fix.
|
||||
|
||||
## Versions
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Spring Boot | 4.1.1 |
|
||||
| Spring Framework | 7.0.9 |
|
||||
| AspectJ weaver | 1.9.25.1 |
|
||||
| JDK | Eclipse Temurin 25.0.4.1 (LTS) |
|
||||
|
||||
## Read this before copying a dependency block
|
||||
|
||||
In Spring Boot 3 the starter was `spring-boot-starter-aop`. In Spring Boot 4 it is
|
||||
**`spring-boot-starter-aspectj`**. The old artifact's last publication is `4.0.0-M2` and its
|
||||
last GA is `3.5.16`, so a pre-4 dependency block fails resolution with a missing-version error.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
export JAVA_HOME=/path/to/jdk-25
|
||||
mvn -DskipTests package
|
||||
./scripts/run-all.sh # regenerate every transcript in docs/output/
|
||||
mvn test # 6 contract tests
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `GET /aop/proxies` | per bean: proxied or not, which kind, and the advisor list |
|
||||
| `GET /aop/designators` | exercises every advised method, reports what each designator matched |
|
||||
| `GET /aop/parser` | feeds supported and unsupported expressions to the pointcut parser |
|
||||
| `GET /aop/broken` | runs the failure gallery and reports that nothing fired |
|
||||
|
||||
## Documentation
|
||||
|
||||
1. [What Spring AOP actually is](docs/01-what-spring-aop-is.md)
|
||||
2. [The designator reference](docs/02-designators.md)
|
||||
3. [JDK dynamic proxies and CGLIB](docs/03-proxy-types.md)
|
||||
4. [Advice types and ordering](docs/04-advice-types.md)
|
||||
5. [Six aspects that do not fire](docs/05-broken-aspect-gallery.md)
|
||||
6. [Diagnosing a silent aspect](docs/06-diagnosing-a-silent-aspect.md)
|
||||
|
||||
## Captured output
|
||||
|
||||
| File | Produced by |
|
||||
|---|---|
|
||||
| [`00-versions.txt`](docs/output/00-versions.txt) | `scripts/demo-versions.sh` |
|
||||
| [`01-designators.txt`](docs/output/01-designators.txt) | `scripts/demo-designators.sh` |
|
||||
| [`02-proxy-types.txt`](docs/output/02-proxy-types.txt) | `scripts/demo-proxy-types.sh` |
|
||||
| [`03-broken-gallery.txt`](docs/output/03-broken-gallery.txt) | `scripts/demo-broken-gallery.sh` |
|
||||
|
||||
## Two corrections to the reference documentation
|
||||
|
||||
- Unsupported designators throw **`UnsupportedPointcutPrimitiveException`**, which extends
|
||||
`RuntimeException` directly — not `IllegalArgumentException` as the docs state. Catching the
|
||||
documented type will not catch it.
|
||||
- The framework default is interface-based proxying, but **Spring Boot sets
|
||||
`proxy-target-class=true`**, so you get CGLIB even for beans that implement interfaces. That
|
||||
changes which designators match; `scripts/demo-proxy-types.sh` shows both.
|
||||
51
spring-aop/docs/01-what-spring-aop-is.md
Normal file
51
spring-aop/docs/01-what-spring-aop-is.md
Normal file
@@ -0,0 +1,51 @@
|
||||
[Index](../README.md) · [Designators →](02-designators.md)
|
||||
|
||||
# 1. What Spring AOP actually is
|
||||
|
||||
Spring AOP is a **proxy** mechanism that borrows AspectJ's **pointcut language**. Both halves
|
||||
of that sentence explain a failure mode.
|
||||
|
||||
## It is proxies
|
||||
|
||||
Spring does not modify your bytecode. When a bean matches a pointcut, the container puts a
|
||||
proxy in the bean registry in its place. Callers get the proxy; the proxy runs advice and then
|
||||
forwards to the real object.
|
||||
|
||||
Everything that follows from this:
|
||||
|
||||
- Only **beans** can be advised. An object created with `new` has no proxy.
|
||||
- Only calls **through the proxy** are intercepted. A call the object makes to itself is not.
|
||||
- Only **overridable** methods can be advised: not private, not final, not static.
|
||||
- Join points are **method executions**. Field access, constructor calls and exception handlers
|
||||
are not available, no matter what the pointcut language allows you to write.
|
||||
|
||||
## It borrows the pointcut language
|
||||
|
||||
`aspectjweaver` on the classpath supplies the pointcut *parser*. Spring uses it to decide which
|
||||
methods match and then does its own weaving with proxies. AspectJ's own weaver is not involved.
|
||||
|
||||
This is why the designator list is a subset: the language can express `call()` and `cflow()`,
|
||||
and a proxy cannot implement them. See [chapter 2](02-designators.md).
|
||||
|
||||
## The Spring Boot 4 starter rename
|
||||
|
||||
In Spring Boot 3 the dependency was `spring-boot-starter-aop`. In Spring Boot 4 it is
|
||||
**`spring-boot-starter-aspectj`**.
|
||||
|
||||
```
|
||||
spring-boot-starter-aop last published 4.0.0-M2 (last GA: 3.5.16)
|
||||
spring-boot-starter-aspectj first published 4.0.0-M3
|
||||
```
|
||||
|
||||
The contents are unchanged: `spring-boot-starter`, `spring-aop`, `aspectjweaver`. But the old
|
||||
artifact is no longer in the Boot BOM, so copying a dependency block out of any pre-4 tutorial
|
||||
fails resolution with a missing-version error rather than a helpful message.
|
||||
|
||||
Without the starter, `@Aspect` classes are ordinary beans, no pointcut is ever parsed, and
|
||||
every aspect in the application silently matches nothing.
|
||||
|
||||
## When to use something else
|
||||
|
||||
If you need to advise field access, constructors, or calls between objects you do not own, you
|
||||
need real AspectJ weaving (compile-time or load-time), not Spring AOP. If you need to advise
|
||||
one internal call, you need to refactor — see [chapter 5](05-broken-aspect-gallery.md).
|
||||
86
spring-aop/docs/02-designators.md
Normal file
86
spring-aop/docs/02-designators.md
Normal file
@@ -0,0 +1,86 @@
|
||||
[← What Spring AOP is](01-what-spring-aop-is.md) · [Index](../README.md) · [Proxy types →](03-proxy-types.md)
|
||||
|
||||
# 2. The designator reference
|
||||
|
||||
Generated by [`DesignatorAspect`](../src/main/java/com/ankurm/aop/aspect/DesignatorAspect.java)
|
||||
and [`PointcutParserEndpoint`](../src/main/java/com/ankurm/aop/web/PointcutParserEndpoint.java).
|
||||
Transcript: [`01-designators.txt`](output/01-designators.txt).
|
||||
|
||||
## Supported
|
||||
|
||||
| Designator | Matches on | Evaluated | Cost |
|
||||
|---|---|---|---|
|
||||
| `execution(...)` | method signature | statically | cheap |
|
||||
| `within(Type)` | the declaring type | statically | cheap |
|
||||
| `this(Type)` | the **proxy**'s type | at runtime | per call |
|
||||
| `target(Type)` | the **target object**'s type | at runtime | per call |
|
||||
| `args(Types)` | argument runtime types; can bind | at runtime | per call |
|
||||
| `@target(Ann)` | annotation on the executing object's class | at runtime | per call |
|
||||
| `@args(Ann)` | annotation on argument runtime types | at runtime | per call |
|
||||
| `@within(Ann)` | annotation on the declaring type | statically | cheap |
|
||||
| `@annotation(Ann)` | annotation on the method | statically | cheap |
|
||||
| `bean(name)` | Spring bean name, wildcards allowed | — | cheap |
|
||||
|
||||
`bean(...)` is Spring's own; it does not exist in AspectJ.
|
||||
|
||||
The runtime group cannot be decided from the signature alone, so Spring must check on every
|
||||
candidate invocation. Prefer the static equivalent where one exists: `@within` instead of
|
||||
`@target`, `within` instead of `this`, when the distinction does not matter.
|
||||
|
||||
## What each one actually matched here
|
||||
|
||||
```
|
||||
execution (full signature) -> DefaultOrderService.place(..)
|
||||
execution (wildcards) -> DefaultOrderService.cancel(..)
|
||||
within -> InventoryService.reserve(..)
|
||||
this(OrderService) -> place, cancel, interfaceless
|
||||
target(DefaultOrderService) -> place, cancel, interfaceless
|
||||
args (bound: SKU-1/2) -> DefaultOrderService.place(..)
|
||||
@target(Audited) -> DefaultOrderService.cancel(..)
|
||||
@args(Trackable) -> DefaultOrderService.interfaceless(..)
|
||||
@within(Audited) -> DefaultOrderService.place(..)
|
||||
@annotation(Marker) -> DefaultOrderService.place(..)
|
||||
bean(inventoryService) -> InventoryService.reserve(..)
|
||||
bean(*OrderService) -> place, cancel, interfaceless
|
||||
```
|
||||
|
||||
`InventoryService.finalCheck` was called and appears nowhere: it is `final`, so no proxy could
|
||||
override it. That is [failure 4](05-broken-aspect-gallery.md).
|
||||
|
||||
## Not supported
|
||||
|
||||
`call`, `get`, `set`, `preinitialization`, `staticinitialization`, `initialization`, `handler`,
|
||||
`adviceexecution`, `withincode`, `cflow`, `cflowbelow`, `if`, `@this`, `@withincode`.
|
||||
|
||||
All fourteen were fed to the parser. All fourteen were rejected, with:
|
||||
|
||||
```
|
||||
org.aspectj.weaver.tools.UnsupportedPointcutPrimitiveException
|
||||
Pointcut expression 'call(* ...place(..))' contains unsupported pointcut primitive 'call'
|
||||
```
|
||||
|
||||
**The reference documentation says these throw `IllegalArgumentException`. They do not.**
|
||||
`UnsupportedPointcutPrimitiveException extends RuntimeException` directly — checked with
|
||||
`javap`, pinned by `AopContractTests.unsupportedDesignatorExceptionType`. A
|
||||
`catch (IllegalArgumentException)` will not catch it.
|
||||
|
||||
## When the failure happens
|
||||
|
||||
`setExpression(...)` only stores the string. The expression is not parsed or validated until
|
||||
something asks it to match. An unsupported designator therefore fails at the first candidate
|
||||
invocation, not at startup — so a rarely-exercised aspect can ship broken.
|
||||
|
||||
## Combining and naming
|
||||
|
||||
`&&`, `||` and `!` compose designators. Name the result rather than repeating it:
|
||||
|
||||
```java
|
||||
@Pointcut("within(com.ankurm.aop.service..*)")
|
||||
public void inServiceLayer() {}
|
||||
|
||||
@Before("this(OrderService) && inServiceLayer()")
|
||||
public void advice(JoinPoint jp) { }
|
||||
```
|
||||
|
||||
A named pointcut is referenced by its method name and is the single biggest readability win
|
||||
available here.
|
||||
78
spring-aop/docs/03-proxy-types.md
Normal file
78
spring-aop/docs/03-proxy-types.md
Normal file
@@ -0,0 +1,78 @@
|
||||
[← Designators](02-designators.md) · [Index](../README.md) · [Advice types →](04-advice-types.md)
|
||||
|
||||
# 3. JDK dynamic proxies and CGLIB
|
||||
|
||||
Transcript: [`02-proxy-types.txt`](output/02-proxy-types.txt).
|
||||
|
||||
| | JDK dynamic proxy | CGLIB |
|
||||
|---|---|---|
|
||||
| Built by | `java.lang.reflect.Proxy` | subclassing the target |
|
||||
| Requires | at least one interface | a non-final class |
|
||||
| Proxy is an instance of | the interfaces only | the target class |
|
||||
| Can advise | public interface methods | public, protected, package-private |
|
||||
| Cannot advise | anything not on the interface | `final`, `private`, `static` |
|
||||
| Class name | `$Proxy62` | `Foo$$SpringCGLIB$$0` |
|
||||
|
||||
## Spring Boot chooses CGLIB
|
||||
|
||||
The Spring Framework's own default is interface-based when an interface exists. **Spring Boot
|
||||
sets `spring.aop.proxy-target-class=true`**, so you get CGLIB either way unless you change it.
|
||||
|
||||
Measured, on a bean that *does* implement an interface:
|
||||
|
||||
```
|
||||
defaultOrderService CGLIB subclass class: DefaultOrderService$$SpringCGLIB$$0
|
||||
interfaces : (none)
|
||||
proxy is an instance of DefaultOrderService : True
|
||||
```
|
||||
|
||||
And with `--spring.aop.proxy-target-class=false`:
|
||||
|
||||
```
|
||||
defaultOrderService JDK dynamic proxy class: $Proxy62
|
||||
interfaces : OrderService
|
||||
proxy is an instance of DefaultOrderService : False
|
||||
```
|
||||
|
||||
## What changes when you switch
|
||||
|
||||
Same aspects, same beans:
|
||||
|
||||
```
|
||||
CGLIB: this(OrderService) -> place, cancel, interfaceless
|
||||
JDK: this(OrderService) -> place, cancel
|
||||
```
|
||||
|
||||
`interfaceless(..)` is not on `OrderService`, so under a JDK proxy it is invisible to advice —
|
||||
and casting the bean to `DefaultOrderService` throws `ClassCastException`.
|
||||
|
||||
This is why `this()` and `target()` exist as separate designators. `this()` tests the proxy;
|
||||
`target()` tests the object behind it. Under CGLIB they nearly always agree, which is exactly
|
||||
why the distinction only ever bites *after* somebody changes the proxy type.
|
||||
|
||||
## CGLIB constraints worth knowing
|
||||
|
||||
- A **final class** cannot be proxied at all — this one fails loudly.
|
||||
- A **final method** is silently not advised. The bean is still a proxy; the method just is not
|
||||
overridden. This is the quiet one.
|
||||
- **Private** methods are never advised by either strategy.
|
||||
- The target's constructor is **not** called twice: Spring creates the proxy instance through
|
||||
Objenesis. On a JVM that forbids constructor bypassing you may see double invocation and a
|
||||
debug log line about it.
|
||||
- On the module path, classes in `java.lang` cannot be proxied without
|
||||
`--add-opens=java.base/java.lang=ALL-UNNAMED`.
|
||||
|
||||
## `@Proxyable`, new in Spring 7.0
|
||||
|
||||
Per-bean control, rather than one global switch:
|
||||
|
||||
```java
|
||||
@Proxyable(ProxyType.INTERFACES)
|
||||
@Bean
|
||||
MyService myService() { ... }
|
||||
```
|
||||
|
||||
Verified against the class file rather than the docs:
|
||||
`org.springframework.context.annotation.Proxyable`, targets `TYPE` and `METHOD`, with
|
||||
`ProxyType value()` (`DEFAULT`, `INTERFACES`, `TARGET_CLASS`) and `Class<?>[] interfaces()`.
|
||||
Note the package — it is in `spring-context`, not `spring-aop`.
|
||||
56
spring-aop/docs/04-advice-types.md
Normal file
56
spring-aop/docs/04-advice-types.md
Normal file
@@ -0,0 +1,56 @@
|
||||
[← Proxy types](03-proxy-types.md) · [Index](../README.md) · [Broken aspect gallery →](05-broken-aspect-gallery.md)
|
||||
|
||||
# 4. Advice types and ordering
|
||||
|
||||
## The five
|
||||
|
||||
| Annotation | Runs | Can it stop the call? | Can it change the result? |
|
||||
|---|---|---|---|
|
||||
| `@Before` | before | only by throwing | no |
|
||||
| `@AfterReturning` | after a normal return | no | no (can read it) |
|
||||
| `@AfterThrowing` | after an exception | no | no (can read it) |
|
||||
| `@After` | after either | no | no |
|
||||
| `@Around` | wraps | yes | yes |
|
||||
|
||||
`@Around` is the only one that receives a `ProceedingJoinPoint` and therefore the only one that
|
||||
can decide whether the target runs at all, retry it, cache around it, or replace its result.
|
||||
|
||||
```java
|
||||
@Around("@annotation(Timed)")
|
||||
public Object time(ProceedingJoinPoint pjp) throws Throwable {
|
||||
long start = System.nanoTime();
|
||||
try {
|
||||
return pjp.proceed();
|
||||
} finally {
|
||||
record(pjp.getSignature(), System.nanoTime() - start);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Two rules for `@Around`: it must return `Object` (or a compatible type) and it must actually
|
||||
call `proceed()`. Forgetting `proceed()` silently turns every advised method into a method that
|
||||
returns `null` and never runs — a failure that looks like the target method being broken.
|
||||
|
||||
## Ordering
|
||||
|
||||
Within one aspect, advice order for the same join point is **not** guaranteed and is not the
|
||||
source order. If two pieces of advice in one aspect must be ordered, split them into two
|
||||
aspects.
|
||||
|
||||
Between aspects, `@Order` or `Ordered` decides. Lower value = higher precedence = outermost.
|
||||
Advice nests: the outermost aspect's `@Before` runs first and its `@After` runs last.
|
||||
|
||||
```
|
||||
@Order(1) Tx : before ... [ @Order(2) Logging : before ... target ... after ] ... after
|
||||
```
|
||||
|
||||
This matters when combining transactions with anything that must be inside or outside them.
|
||||
`@Transactional` is ordered at `Ordered.LOWEST_PRECEDENCE` by default, so almost everything
|
||||
else wraps *outside* it — meaning your logging advice sees the method return before the
|
||||
transaction commits, and therefore before a commit failure has happened.
|
||||
|
||||
## `ExposeInvocationInterceptor`
|
||||
|
||||
You will see this at the head of every advisor chain in
|
||||
[`02-proxy-types.txt`](output/02-proxy-types.txt). Spring adds it automatically so that
|
||||
`AopContext.currentProxy()` and argument binding work. It is not something you configured.
|
||||
84
spring-aop/docs/05-broken-aspect-gallery.md
Normal file
84
spring-aop/docs/05-broken-aspect-gallery.md
Normal file
@@ -0,0 +1,84 @@
|
||||
[← Advice types](04-advice-types.md) · [Index](../README.md) · [Diagnosing →](06-diagnosing-a-silent-aspect.md)
|
||||
|
||||
# 5. Six aspects that do not fire
|
||||
|
||||
Sources: [`BrokenAspects`](../src/main/java/com/ankurm/aop/broken/BrokenAspects.java),
|
||||
[`SelfInvokingService`](../src/main/java/com/ankurm/aop/broken/SelfInvokingService.java),
|
||||
[`NewedUpService`](../src/main/java/com/ankurm/aop/broken/NewedUpService.java).
|
||||
Transcript: [`03-broken-gallery.txt`](output/03-broken-gallery.txt).
|
||||
|
||||
None of these warn. None fail at startup. All of them look correct in review.
|
||||
|
||||
## 1. `@Aspect` without `@Component`
|
||||
|
||||
`@Aspect` is an AspectJ annotation. It tells Spring how to interpret a bean it already has; it
|
||||
does not create one. Without a stereotype or an `@Bean` method, the class is never instantiated
|
||||
and the pointcut is never registered.
|
||||
|
||||
The most common cause, and the most invisible — from the container's point of view nothing was
|
||||
ever requested, so there is nothing to warn about.
|
||||
|
||||
**Fix:** add `@Component`.
|
||||
|
||||
## 2. A pointcut that matches nothing
|
||||
|
||||
```java
|
||||
@Before("execution(* com.ankurm.aop.services.*.*(..))") // "services", plural
|
||||
```
|
||||
|
||||
A package name matching no type is not an error; it is an empty match set. At runtime this is
|
||||
indistinguishable from an aspect that was never registered.
|
||||
|
||||
**Fix:** assert on it. `AspectJExpressionPointcut#matches(Method, Class)` in a unit test is two
|
||||
lines and catches every typo permanently.
|
||||
|
||||
## 3. A private method
|
||||
|
||||
Neither proxy strategy can override a private method, so neither can intercept it. The
|
||||
annotation is legal and inert.
|
||||
|
||||
**Fix:** make it at least package-private *and* call it from outside the object — visibility
|
||||
alone is not enough if the call is internal, which brings you to number 5.
|
||||
|
||||
## 4. A final method
|
||||
|
||||
CGLIB proxies by subclassing. A final method is inherited rather than overridden, so calls go
|
||||
straight to the original. A final *class* fails loudly; a final *method* is silent.
|
||||
|
||||
Note `beanIsProxied: true` in the transcript. The bean is proxied. This one method is not.
|
||||
|
||||
**Fix:** remove `final`, or proxy by interface.
|
||||
|
||||
## 5. Self-invocation
|
||||
|
||||
The expensive one, because the code looks right and the annotation is visible.
|
||||
|
||||
```json
|
||||
"5-self-invocation": {
|
||||
"beanIsProxied": true,
|
||||
"innerAdvisedWhenCalledFromOuter": false,
|
||||
"innerAdvisedWhenCalledDirectly": true
|
||||
}
|
||||
```
|
||||
|
||||
Same method, same advice. Called through the proxy it is advised; reached by `this.inner()`
|
||||
from another method of the same object it is not, because the proxy is not in that call path.
|
||||
|
||||
This is the same mechanism that makes `@Transactional` and `@Cacheable` silently do nothing on
|
||||
internal calls. Learning it once here saves learning it three times.
|
||||
|
||||
**Fixes, best first:**
|
||||
|
||||
1. Move the method to another bean. This is almost always the right answer, and the resulting
|
||||
design is usually better anyway.
|
||||
2. Inject the bean into itself and call through that reference.
|
||||
3. `AopContext.currentProxy()` with `exposeProxy = true`. Works; couples your code to Spring
|
||||
AOP and makes the class aware it is proxied.
|
||||
|
||||
## 6. An object created with `new`
|
||||
|
||||
Spring AOP advises beans. An instance built by a factory, a helper or a test has no proxy and
|
||||
never will.
|
||||
|
||||
**Fix:** get it from the container. If it genuinely must be constructed by hand and still
|
||||
advised, that is what AspectJ load-time weaving is for.
|
||||
75
spring-aop/docs/06-diagnosing-a-silent-aspect.md
Normal file
75
spring-aop/docs/06-diagnosing-a-silent-aspect.md
Normal file
@@ -0,0 +1,75 @@
|
||||
[← Broken aspect gallery](05-broken-aspect-gallery.md) · [Index](../README.md)
|
||||
|
||||
# 6. Diagnosing a silent aspect
|
||||
|
||||
Endpoint: [`AopDiagnosticsEndpoint`](../src/main/java/com/ankurm/aop/web/AopDiagnosticsEndpoint.java).
|
||||
|
||||
Almost every non-firing aspect is one of three things, and all three are visible in one place.
|
||||
|
||||
## The three questions, in order
|
||||
|
||||
**1. Is the bean proxied at all?**
|
||||
|
||||
```java
|
||||
AopUtils.isAopProxy(bean)
|
||||
```
|
||||
|
||||
`false` means no pointcut matched this bean, or it is not a bean. Stop here and check the
|
||||
pointcut and the registration.
|
||||
|
||||
**2. Which kind of proxy?**
|
||||
|
||||
```java
|
||||
AopUtils.isJdkDynamicProxy(bean) // interfaces only
|
||||
AopUtils.isCglibProxy(bean) // subclass
|
||||
```
|
||||
|
||||
If it is a JDK proxy and your method is not on an interface, that is your answer.
|
||||
|
||||
**3. Is your advice in the advisor list?**
|
||||
|
||||
```java
|
||||
if (bean instanceof Advised advised) {
|
||||
Arrays.stream(advised.getAdvisors())
|
||||
.map(a -> a.getAdvice().getClass().getSimpleName())
|
||||
.forEach(System.out::println);
|
||||
}
|
||||
```
|
||||
|
||||
Proxied, right kind, and your advice missing means the pointcut matched the *bean* but not the
|
||||
*method*.
|
||||
|
||||
## Output
|
||||
|
||||
```
|
||||
defaultOrderService CGLIB subclass target=DefaultOrderService
|
||||
class : DefaultOrderService$$SpringCGLIB$$0
|
||||
interfaces : (none)
|
||||
advisors : 11
|
||||
```
|
||||
|
||||
## Testing a pointcut without an application
|
||||
|
||||
The fastest check of all, and it belongs in a test rather than in a diagnostic endpoint:
|
||||
|
||||
```java
|
||||
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
|
||||
pointcut.setExpression("execution(* com.example.service.*.*(..))");
|
||||
assertThat(pointcut.matches(Foo.class.getMethod("bar"), Foo.class)).isTrue();
|
||||
```
|
||||
|
||||
Remember that `setExpression` does not parse; the parse happens on first `matches`. So this is
|
||||
also how you find out that a designator is unsupported before production does.
|
||||
|
||||
## Logging
|
||||
|
||||
```
|
||||
logging.level.org.springframework.aop=DEBUG
|
||||
```
|
||||
|
||||
reports proxy creation per bean. Verbose, but it answers question 1 for every bean at once.
|
||||
|
||||
## Delete the endpoint before shipping
|
||||
|
||||
It reports internal wiring. If you want it permanently, put it behind the management port and
|
||||
authentication.
|
||||
11
spring-aop/docs/output/00-versions.txt
Normal file
11
spring-aop/docs/output/00-versions.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
== versions ==
|
||||
openjdk version "25.0.4.1" 2026-08-18 LTS
|
||||
OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)
|
||||
OpenJDK 64-Bit Server VM Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS, mixed mode, sharing)
|
||||
|
||||
spring-boot-starter-parent: 4.1.1
|
||||
aspectjweaver: 1.9.25.1
|
||||
|
||||
== the Boot 4 starter rename ==
|
||||
spring-boot-starter-aop last published: 4.0.0-M2 (last GA 3.5.16)
|
||||
spring-boot-starter-aspectj first published: 4.0.0-M3
|
||||
59
spring-aop/docs/output/01-designators.txt
Normal file
59
spring-aop/docs/output/01-designators.txt
Normal file
@@ -0,0 +1,59 @@
|
||||
== which designator matched which join point ==
|
||||
|
||||
Five methods are called once each: OrderService.place, OrderService.cancel,
|
||||
InventoryService.reserve, InventoryService.finalCheck and
|
||||
DefaultOrderService.interfaceless.
|
||||
|
||||
orderService proxy kind : CGLIB subclass
|
||||
proxy is an instance of DefaultOrderService : True
|
||||
|
||||
args (bound: SKU-1/2) -> DefaultOrderService.place(..)
|
||||
bean(*OrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..)
|
||||
@within(Audited) -> DefaultOrderService.place(..)
|
||||
execution (full signature) -> DefaultOrderService.place(..)
|
||||
@annotation(Marker) -> DefaultOrderService.place(..)
|
||||
target(DefaultOrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..)
|
||||
this(OrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..)
|
||||
execution (wildcards) -> DefaultOrderService.cancel(..)
|
||||
@target(Audited) -> DefaultOrderService.cancel(..)
|
||||
bean(inventoryService) -> InventoryService.reserve(..)
|
||||
within -> InventoryService.reserve(..)
|
||||
@args(Trackable) -> DefaultOrderService.interfaceless(..)
|
||||
|
||||
== what the parser accepts and refuses ==
|
||||
|
||||
supported (all parsed and evaluated):
|
||||
execution(* com.ankurm.aop.service.OrderService.place(..)) OK
|
||||
within(com.ankurm.aop.service..*) OK
|
||||
this(com.ankurm.aop.service.OrderService) OK
|
||||
target(com.ankurm.aop.service.DefaultOrderService) OK
|
||||
args(String, int) OK
|
||||
@target(com.ankurm.aop.service.Audited) OK
|
||||
@args(com.ankurm.aop.service.Trackable) OK
|
||||
@within(com.ankurm.aop.service.Audited) OK
|
||||
@annotation(com.ankurm.aop.service.Marker) OK
|
||||
bean(defaultOrderService) OK
|
||||
|
||||
unsupported in Spring AOP:
|
||||
call(* com.ankurm.aop.service.OrderService.place(. rejected
|
||||
get(* com.ankurm.aop.service.*.*) rejected
|
||||
set(* com.ankurm.aop.service.*.*) rejected
|
||||
initialization(com.ankurm.aop.service.*.new(..)) rejected
|
||||
staticinitialization(com.ankurm.aop.service.*) rejected
|
||||
preinitialization(com.ankurm.aop.service.*.new(..) rejected
|
||||
handler(java.lang.Exception) rejected
|
||||
adviceexecution() rejected
|
||||
withincode(* com.ankurm.aop.service.*.*(..)) rejected
|
||||
cflow(execution(* com.ankurm.aop.service.*.*(..))) rejected
|
||||
cflowbelow(execution(* com.ankurm.aop.service.*.*( rejected
|
||||
if() rejected
|
||||
@this(com.ankurm.aop.service.Audited) rejected
|
||||
@withincode(com.ankurm.aop.service.Marker) rejected
|
||||
|
||||
the exception, in full:
|
||||
org.aspectj.weaver.tools.UnsupportedPointcutPrimitiveException
|
||||
Pointcut expression 'call(* com.ankurm.aop.service.OrderService.place(..))' contains unsupported pointcut primitive 'call'
|
||||
|
||||
The reference documentation says these produce an IllegalArgumentException. They do
|
||||
not: UnsupportedPointcutPrimitiveException extends RuntimeException directly, so a
|
||||
catch of IllegalArgumentException will not catch it.
|
||||
53
spring-aop/docs/output/02-proxy-types.txt
Normal file
53
spring-aop/docs/output/02-proxy-types.txt
Normal file
@@ -0,0 +1,53 @@
|
||||
== Spring Boot default: spring.aop.proxy-target-class=true ==
|
||||
$ java -jar target/spring-aop-demo-1.0.0.jar
|
||||
|
||||
defaultOrderService CGLIB subclass target=DefaultOrderService
|
||||
class : DefaultOrderService$$SpringCGLIB$$0
|
||||
interfaces : (none)
|
||||
advisors : 11
|
||||
inventoryService CGLIB subclass target=InventoryService
|
||||
class : InventoryService$$SpringCGLIB$$0
|
||||
interfaces : (none)
|
||||
advisors : 6
|
||||
selfInvokingService CGLIB subclass target=SelfInvokingService
|
||||
class : SelfInvokingService$$SpringCGLIB$$0
|
||||
interfaces : (none)
|
||||
advisors : 2
|
||||
|
||||
proxy is an instance of DefaultOrderService : True
|
||||
this(OrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..)
|
||||
target(DefaultOrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..)
|
||||
bean(*OrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..)
|
||||
|
||||
== framework default restored: spring.aop.proxy-target-class=false ==
|
||||
$ java -jar target/spring-aop-demo-1.0.0.jar --spring.aop.proxy-target-class=false
|
||||
|
||||
defaultOrderService JDK dynamic proxy target=DefaultOrderService
|
||||
class : $Proxy62
|
||||
interfaces : OrderService
|
||||
advisors : 11
|
||||
inventoryService CGLIB subclass target=InventoryService
|
||||
class : InventoryService$$SpringCGLIB$$0
|
||||
interfaces : (none)
|
||||
advisors : 6
|
||||
selfInvokingService CGLIB subclass target=SelfInvokingService
|
||||
class : SelfInvokingService$$SpringCGLIB$$0
|
||||
interfaces : (none)
|
||||
advisors : 2
|
||||
|
||||
proxy is an instance of DefaultOrderService : False
|
||||
this(OrderService) -> OrderService.place(..), OrderService.cancel(..)
|
||||
target(DefaultOrderService) -> OrderService.place(..), OrderService.cancel(..)
|
||||
bean(*OrderService) -> OrderService.place(..), OrderService.cancel(..)
|
||||
|
||||
Same aspects, same beans, different proxy strategy:
|
||||
|
||||
- With CGLIB the proxy is a SUBCLASS of DefaultOrderService, so it is an instance of
|
||||
the implementation class and methods that are not on the interface are advised.
|
||||
- With a JDK proxy the proxy implements OrderService only. It is NOT an instance of
|
||||
DefaultOrderService, casting to that class throws ClassCastException, and any
|
||||
method absent from the interface is invisible to advice.
|
||||
|
||||
This is why this() and target() differ. this() tests the proxy; target() tests the
|
||||
object behind it. Under CGLIB they usually agree, which is exactly why the
|
||||
distinction only bites after somebody switches the proxy type.
|
||||
37
spring-aop/docs/output/03-broken-gallery.txt
Normal file
37
spring-aop/docs/output/03-broken-gallery.txt
Normal file
@@ -0,0 +1,37 @@
|
||||
== aspects that do not fire ==
|
||||
|
||||
{
|
||||
"5-self-invocation": {
|
||||
"beanIsProxied": true,
|
||||
"result": "outer -> inner",
|
||||
"innerAdvisedWhenCalledFromOuter": false,
|
||||
"innerAdvisedWhenCalledDirectly": true
|
||||
},
|
||||
"6-created-with-new": {
|
||||
"isProxy": false,
|
||||
"adviceFired": false
|
||||
},
|
||||
"4-final-method": {
|
||||
"beanIsProxied": true,
|
||||
"proxyKind": "CGLIB subclass",
|
||||
"adviceFired": false
|
||||
},
|
||||
"1-aspect-without-component-fired": false,
|
||||
"2-pointcut-typo-fired": false,
|
||||
"3-private-method-fired": false,
|
||||
"note": "every value above should be false except the two that prove the method is advisable when reached through the proxy"
|
||||
}
|
||||
|
||||
Reading it:
|
||||
|
||||
1 @Aspect without @Component - the class is never instantiated, so the pointcut
|
||||
is never registered. No warning is produced.
|
||||
2 pointcut typo - 'com.ankurm.aop.services' (plural) parses fine and
|
||||
matches nothing. An empty match set is not an error.
|
||||
3 private method - cannot be overridden, so cannot be intercepted.
|
||||
4 final method - CGLIB subclasses; a final method is inherited, not
|
||||
overridden. Note beanIsProxied is still true.
|
||||
5 self-invocation - innerAdvisedWhenCalledFromOuter is false and
|
||||
innerAdvisedWhenCalledDirectly is true. Same method,
|
||||
same advice: only the call path differs.
|
||||
6 created with new - no container, no proxy, no advice.
|
||||
60
spring-aop/pom.xml
Normal file
60
spring-aop/pom.xml
Normal file
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>spring-aop-demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>spring-aop-demo</name>
|
||||
<description>Spring AOP: pointcut designators, proxy types, and why aspects do not fire</description>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
</dependency>
|
||||
<!-- NOTE THE NAME. In Spring Boot 3 this was spring-boot-starter-aop. That artifact was
|
||||
renamed to spring-boot-starter-aspectj during the Boot 4 milestones: its last
|
||||
published version is 4.0.0-M2 and its last GA is 3.5.16, while
|
||||
spring-boot-starter-aspectj starts at 4.0.0-M3. Copying an AOP dependency block out
|
||||
of any pre-4 tutorial therefore fails resolution, because the old name is no longer
|
||||
in the Boot BOM and therefore has no managed version.
|
||||
|
||||
Contents are unchanged: spring-boot-starter, spring-aop and aspectjweaver. It is
|
||||
aspectjweaver that supplies the pointcut PARSER: Spring AOP does its own weaving
|
||||
at runtime with proxies, and borrows only the expression language from AspectJ.
|
||||
Without it, @Aspect classes are ordinary beans and every pointcut here silently
|
||||
matches nothing. See docs/01-what-spring-aop-is.md. -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aspectj</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
28
spring-aop/scripts/demo-broken-gallery.sh
Executable file
28
spring-aop/scripts/demo-broken-gallery.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# The broken-aspect gallery: six aspects that do not fire, and why.
|
||||
set -euo pipefail
|
||||
set +m
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh
|
||||
{
|
||||
echo "== aspects that do not fire =="
|
||||
echo
|
||||
start_app > /dev/null
|
||||
curl -s "http://127.0.0.1:${APP_PORT}/aop/broken" | python3 -m json.tool
|
||||
echo
|
||||
echo "Reading it:"
|
||||
echo
|
||||
echo " 1 @Aspect without @Component - the class is never instantiated, so the pointcut"
|
||||
echo " is never registered. No warning is produced."
|
||||
echo " 2 pointcut typo - 'com.ankurm.aop.services' (plural) parses fine and"
|
||||
echo " matches nothing. An empty match set is not an error."
|
||||
echo " 3 private method - cannot be overridden, so cannot be intercepted."
|
||||
echo " 4 final method - CGLIB subclasses; a final method is inherited, not"
|
||||
echo " overridden. Note beanIsProxied is still true."
|
||||
echo " 5 self-invocation - innerAdvisedWhenCalledFromOuter is false and"
|
||||
echo " innerAdvisedWhenCalledDirectly is true. Same method,"
|
||||
echo " same advice: only the call path differs."
|
||||
echo " 6 created with new - no container, no proxy, no advice."
|
||||
stop_app
|
||||
} > docs/output/03-broken-gallery.txt 2>&1
|
||||
cat docs/output/03-broken-gallery.txt
|
||||
48
spring-aop/scripts/demo-designators.sh
Executable file
48
spring-aop/scripts/demo-designators.sh
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Every pointcut designator Spring AOP supports, with what it actually matched.
|
||||
set -euo pipefail
|
||||
set +m
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh
|
||||
{
|
||||
echo "== which designator matched which join point =="
|
||||
echo
|
||||
echo "Five methods are called once each: OrderService.place, OrderService.cancel,"
|
||||
echo "InventoryService.reserve, InventoryService.finalCheck and"
|
||||
echo "DefaultOrderService.interfaceless."
|
||||
echo
|
||||
start_app > /dev/null
|
||||
curl -s "http://127.0.0.1:${APP_PORT}/aop/designators" | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
print(" orderService proxy kind :", d["orderServiceProxyKind"])
|
||||
print(" proxy is an instance of DefaultOrderService :",
|
||||
d["orderServiceIsDefaultOrderServiceInstance"])
|
||||
print()
|
||||
for k,v in d["matches"].items():
|
||||
print(" %-32s -> %s" % (k, ", ".join(v)))'
|
||||
echo
|
||||
echo "== what the parser accepts and refuses =="
|
||||
echo
|
||||
curl -s "http://127.0.0.1:${APP_PORT}/aop/parser" | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
print(" supported (all parsed and evaluated):")
|
||||
for r in d["supported"]:
|
||||
print(" %-52s %s" % (r["expression"], "OK" if r["accepted"] else "REJECTED"))
|
||||
print()
|
||||
print(" unsupported in Spring AOP:")
|
||||
for r in d["unsupported"]:
|
||||
print(" %-52s %s" % (r["expression"][:50], "accepted!" if r["accepted"] else "rejected"))
|
||||
print()
|
||||
first=[r for r in d["unsupported"] if not r["accepted"]][0]
|
||||
print(" the exception, in full:")
|
||||
print(" " + first["exception"])
|
||||
print(" " + first["message"])'
|
||||
echo
|
||||
echo "The reference documentation says these produce an IllegalArgumentException. They do"
|
||||
echo "not: UnsupportedPointcutPrimitiveException extends RuntimeException directly, so a"
|
||||
echo "catch of IllegalArgumentException will not catch it."
|
||||
stop_app
|
||||
} > docs/output/01-designators.txt 2>&1
|
||||
cat docs/output/01-designators.txt
|
||||
56
spring-aop/scripts/demo-proxy-types.sh
Executable file
56
spring-aop/scripts/demo-proxy-types.sh
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# JDK dynamic proxies versus CGLIB, and what changes when you switch.
|
||||
#
|
||||
# Spring Boot sets spring.aop.proxy-target-class=true by default, so beans are proxied by
|
||||
# CGLIB even when they implement an interface. Setting it to false restores the framework's
|
||||
# own default and changes which designators match -- the same aspects, different results.
|
||||
set -euo pipefail
|
||||
set +m
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh
|
||||
|
||||
snapshot() {
|
||||
curl -s "http://127.0.0.1:${APP_PORT}/aop/proxies" | python3 -c '
|
||||
import json,sys
|
||||
for r in json.load(sys.stdin):
|
||||
print(" %-22s %-18s target=%s" % (r["bean"], r["proxyKind"], r["targetClass"].split(".")[-1]))
|
||||
print(" class : %s" % r["class"].split(".")[-1])
|
||||
print(" interfaces : %s" % (", ".join(r.get("proxiedInterfaces") or []) or "(none)"))
|
||||
print(" advisors : %d" % r.get("advisorCount", 0))'
|
||||
echo
|
||||
curl -s "http://127.0.0.1:${APP_PORT}/aop/designators" | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
print(" proxy is an instance of DefaultOrderService :",
|
||||
d["orderServiceIsDefaultOrderServiceInstance"])
|
||||
for k in ("this(OrderService)","target(DefaultOrderService)","bean(*OrderService)"):
|
||||
print(" %-30s -> %s" % (k, ", ".join(d["matches"].get(k, ["(no match)"]))))'
|
||||
}
|
||||
|
||||
{
|
||||
echo "== Spring Boot default: spring.aop.proxy-target-class=true =="
|
||||
echo "\$ java -jar $JAR"
|
||||
echo
|
||||
start_app > /dev/null
|
||||
snapshot
|
||||
echo
|
||||
echo "== framework default restored: spring.aop.proxy-target-class=false =="
|
||||
echo "\$ java -jar $JAR --spring.aop.proxy-target-class=false"
|
||||
echo
|
||||
start_app --spring.aop.proxy-target-class=false > /dev/null
|
||||
snapshot
|
||||
echo
|
||||
echo "Same aspects, same beans, different proxy strategy:"
|
||||
echo
|
||||
echo " - With CGLIB the proxy is a SUBCLASS of DefaultOrderService, so it is an instance of"
|
||||
echo " the implementation class and methods that are not on the interface are advised."
|
||||
echo " - With a JDK proxy the proxy implements OrderService only. It is NOT an instance of"
|
||||
echo " DefaultOrderService, casting to that class throws ClassCastException, and any"
|
||||
echo " method absent from the interface is invisible to advice."
|
||||
echo
|
||||
echo "This is why this() and target() differ. this() tests the proxy; target() tests the"
|
||||
echo "object behind it. Under CGLIB they usually agree, which is exactly why the"
|
||||
echo "distinction only bites after somebody switches the proxy type."
|
||||
stop_app
|
||||
} > docs/output/02-proxy-types.txt 2>&1
|
||||
cat docs/output/02-proxy-types.txt
|
||||
16
spring-aop/scripts/demo-versions.sh
Executable file
16
spring-aop/scripts/demo-versions.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh
|
||||
{
|
||||
echo "== versions =="
|
||||
java -version 2>&1 | clean
|
||||
echo
|
||||
echo "spring-boot-starter-parent: $(grep -A2 '<artifactId>spring-boot-starter-parent' pom.xml | grep '<version>' | sed 's/.*<version>\(.*\)<\/version>.*/\1/')"
|
||||
echo "aspectjweaver: $(find ~/.m2/repository -name 'aspectjweaver-*.jar' | sed 's/.*aspectjweaver-//;s/\.jar//' | sort | tail -1)"
|
||||
echo
|
||||
echo "== the Boot 4 starter rename =="
|
||||
echo "spring-boot-starter-aop last published: 4.0.0-M2 (last GA 3.5.16)"
|
||||
echo "spring-boot-starter-aspectj first published: 4.0.0-M3"
|
||||
} > docs/output/00-versions.txt 2>&1
|
||||
cat docs/output/00-versions.txt
|
||||
61
spring-aop/scripts/env.sh
Executable file
61
spring-aop/scripts/env.sh
Executable file
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared environment. Point JAVA_HOME at a JDK 25 (or newer) installation.
|
||||
: "${JAVA_HOME:?set JAVA_HOME to a JDK 25+ installation}"
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
MVN="${MVN:-mvn}"
|
||||
JAR="target/spring-aop-demo-1.0.0.jar"
|
||||
APP_MAIN="com.ankurm.aop.AopApplication"
|
||||
APP_PORT="${APP_PORT:-8080}"
|
||||
|
||||
# Strip environment noise that is an artefact of the machine, not of Spring:
|
||||
# the JVM prints a JAVA_TOOL_OPTIONS banner to stderr on every launch when a proxy
|
||||
# truststore is configured, and it would otherwise end up in every committed transcript.
|
||||
clean() { grep -v "Picked up JAVA_TOOL_OPTIONS" | grep -v "^OpenJDK 64-Bit Server VM warning"; }
|
||||
|
||||
# Start the demo jar detached, record its PID, and block until it answers.
|
||||
# Extra arguments are passed to the application. Environment variables for the run are
|
||||
# passed by setting them on the call: `APP_ENV="A=1 B=2" start_app --spring.profiles.active=x`
|
||||
start_app() {
|
||||
stop_app
|
||||
mkdir -p target
|
||||
# Deliberately NOT setsid: setsid forks when it is not already a process-group leader,
|
||||
# so $! would be the PID of a process that exits immediately and the JVM would survive
|
||||
# every later stop_app. A surviving JVM keeps the port, the next scenario fails to bind,
|
||||
# and curl answers from the previous scenario -- which reads exactly like the
|
||||
# configuration change under test having had no effect. Three wrong findings in this
|
||||
# repository came from that before it was tracked down.
|
||||
if [ -n "${APP_ENV:-}" ]; then
|
||||
# shellcheck disable=SC2086
|
||||
env $APP_ENV nohup java -jar "$JAR" "$@" > /tmp/aop-demo.log 2>&1 < /dev/null &
|
||||
else
|
||||
nohup java -jar "$JAR" "$@" > /tmp/aop-demo.log 2>&1 < /dev/null &
|
||||
fi
|
||||
echo $! > target/app.pid
|
||||
for _ in $(seq 1 60); do
|
||||
curl -s -o /dev/null "http://127.0.0.1:${APP_PORT}/aop/proxies" 2>/dev/null && return 0
|
||||
kill -0 "$(cat target/app.pid)" 2>/dev/null || { echo "JVM exited during startup:"
|
||||
tail -20 /tmp/aop-demo.log; return 1; }
|
||||
sleep 1
|
||||
done
|
||||
echo "application did not answer"; tail -20 /tmp/aop-demo.log; return 1
|
||||
}
|
||||
|
||||
# Stop it by recorded PID. Never by pattern: `ps | grep <jar name>` also matches the shell
|
||||
# running the script, because the jar name is on that shell's own command line.
|
||||
stop_app() {
|
||||
if [ -f target/app.pid ]; then
|
||||
pid=$(cat target/app.pid)
|
||||
if [ -n "$pid" ] && grep -qa "spring-aop-demo" "/proc/$pid/cmdline" 2>/dev/null; then
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true # reap, so bash prints no "Killed" notice
|
||||
fi
|
||||
rm -f target/app.pid
|
||||
fi
|
||||
for _ in $(seq 1 40); do
|
||||
if ! (exec 3<>/dev/tcp/127.0.0.1/"${APP_PORT:-8080}") 2>/dev/null; then break; fi
|
||||
sleep 0.25
|
||||
done
|
||||
exec 3<&- 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Print the precedence report for one property, compactly.
|
||||
16
spring-aop/scripts/run-all.sh
Executable file
16
spring-aop/scripts/run-all.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerate every transcript under docs/output/.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh
|
||||
|
||||
"$MVN" -B -q package -DskipTests
|
||||
|
||||
for demo in versions designators proxy-types broken-gallery; do
|
||||
echo "=== $demo ==="
|
||||
"scripts/demo-$demo.sh" > /dev/null
|
||||
done
|
||||
stop_app
|
||||
echo
|
||||
echo "regenerated:"
|
||||
ls -1 docs/output/
|
||||
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