spring-bootaopaspectjspring-aopspring-framework-beans

AOP Triggering Twice with @Autowired or Constructor Injection in Spring Boot 3.1.12


MCVE On guthub

After upgrading org.springframework.boot from version 3.1.6 to 3.1.12, the class function calls that are injected using @Autowired or through constructor injection trigger the AOP twice. However, this issue does not occur when invoking functions within the same class, using objects instantiated with new, or utilizing static function calls across classes.

Logs:

2024-12-23 11:06:48,721 INFO MonitorMethodAspect            L329 : thread_id=59 thread_name=http-nio-8080-exec-1 thread_priority=5 execution(String com.controller.KnowledgeBaseController.test()) test
2024-12-23 11:06:48,725 INFO MonitorMethodAspect            L329 : thread_id=59 thread_name=http-nio-8080-exec-1 thread_priority=5 execution(String com.controller.KnowledgeBaseController.test()) test

Other logs with little different, the space between joint point function parameters.

2024-12-23 11:19:36,406 INFO MonitorMethodAspect            L230 : thread_id=66 thread_name=http-nio-8080-exec-8 thread_priority=5 execution(Flux com.message.net.impl.MultiLlmClientImpl.callStreaming(ChatRequest, ResourceType, Message, Message, UUID)) FluxAutoConnectFuseable
2024-12-23 11:19:36,739 INFO MonitorMethodAspect            L230 : thread_id=66 thread_name=http-nio-8080-exec-8 thread_priority=5 execution(Flux com.message.net.impl.MultiLlmClientImpl.callStreaming(ChatRequest,ResourceType,Message,Message,UUID)) FluxAutoConnectFuseable

To clarify, com.service.KnowledgeBaseService.test method is confirmed to only exist once and is not duplicated within the codebase.

Additionally, org.springframework.boot version 3.1.11 works fine, but this issue still exists through version 3.2.2 (the latest version my project can be upgraded to).

Is this a version-specific issue that will be resolved in a future release, or do I need to modify my implementation to address this?

AspectConfig:

@Configuration
@ComponentScan("com.monitor")
public class AspectConfig {
  @Bean
  public MonitorMethodAspect theAspect() {
    MonitorMethodAspect aspect = Aspects.aspectOf(MonitorMethodAspect.class);
    return aspect;
  }
}

MonitorMethodAspect:

@Aspect
public class MonitorMethodAspect {
  @Autowired MonitorService monitorService;
  @AfterReturning(pointcut  = "execution(* com.controller.KnowledgeBaseController.test(..))", returning = "result")
  public void test(JoinPoint joinPoint, String result){
    log.info(joinPoint+" "+result);
  }
}

KnowledgeBaseController:

@RestController
public class KnowledgeBaseController {
  public String test(){
    return "test";
  }
}

build.gradle:

plugins {
  id 'java'
  id "io.freefair.aspectj.post-compile-weaving" version "8.0.1"
  id 'org.asciidoctor.convert' version '1.5.8'
  id 'org.springframework.boot' version '3.1.12'
  id 'io.spring.dependency-management' version '1.1.0'
  id 'com.diffplug.spotless' version '6.17.0'
  id 'jacoco'
  id 'project-report'
  id("com.google.osdetector") version "1.7.1"
}

spotless {
    java {
        importOrder()
        removeUnusedImports()
        googleJavaFormat()
        formatAnnotations()
    }
}

version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
  compileOnly {
    extendsFrom annotationProcessor
  }
}

repositories {
  mavenCentral()
}

bootRun {
    jvmArgs = [
        "-Dspring.profiles.active=rd",
    ]
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-web'
  implementation 'org.springframework.boot:spring-boot-starter-actuator'
  implementation 'org.springframework.boot:spring-boot-starter-validation'
  implementation 'com.fasterxml.jackson.core:jackson-core:2.15.3'
  implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.3'
  implementation 'org.springframework.boot:spring-boot-starter-webflux'

  implementation "com.google.guava:guava:32.0.1-jre"
  runtimeOnly 'org.aspectj:aspectjrt:1.9.19'
  runtimeOnly 'org.aspectj:aspectjweaver:1.9.19'

  implementation 'org.apache.pdfbox:pdfbox:2.0.24'

  implementation group: 'com.microsoft.azure', name: 'adal4j', version: '1.6.6'
  implementation group: 'com.nimbusds', name: 'oauth2-oidc-sdk'
  implementation group: 'net.javacrumbs.shedlock', name: 'shedlock-spring', version: '4.33.0'
  implementation group: 'net.javacrumbs.shedlock', name: 'shedlock-provider-jdbc-template', version: '4.33.0'

  if (osdetector.classifier == "osx-aarch_64") {
    runtimeOnly('io.netty:netty-all:4.1.89.Final')
  }

  compileOnly 'org.projectlombok:lombok'
  annotationProcessor 'org.projectlombok:lombok'

  implementation 'org.springframework.boot:spring-boot-starter-security'
  testImplementation 'org.springframework.security:spring-security-test'

  implementation 'org.springframework.security:spring-security-jwt'
  implementation 'org.springframework.security.oauth.boot:spring-security-oauth2-autoconfigure:2.6.8'
  implementation 'com.auth0:java-jwt:3.19.3'

  implementation 'com.microsoft.graph:microsoft-graph:5.41.0'
  implementation 'com.squareup.okhttp3:okhttp:4.11.0'
  implementation 'com.azure:azure-identity:1.10.4'
  implementation 'org.springframework.security:spring-security-oauth2-client'
  implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'

  implementation 'org.apache.commons:commons-lang3:3.8.1'
  implementation 'org.apache.commons:commons-collections4:4.4'
  implementation 'commons-beanutils:commons-beanutils:1.9.4'
  implementation 'com.google.code.gson:gson:2.10'
  implementation 'org.springframework.retry:spring-retry:2.0.2'
  implementation 'com.knuddels:jtokkit:1.1.0'

  implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0'

  // test
  testImplementation('org.junit.jupiter:junit-jupiter:5.9.3')
  testImplementation ('org.springframework.boot:spring-boot-starter-test')
  {
    exclude group: 'org.mockito', module :'mockito-core'
  }

  testImplementation 'org.mockito:mockito-inline:5.2.0'
  implementation 'io.opentelemetry:opentelemetry-api:1.26.0'

  implementation 'org.springframework.boot:spring-boot-starter-log4j2'
  modules {
      module("org.springframework.boot:spring-boot-starter-logging") {
          replacedBy 'org.springframework.boot:spring-boot-starter-log4j2'
      }
  }
  implementation platform('io.micrometer:micrometer-tracing-bom:1.0.4')
  implementation 'io.micrometer:micrometer-tracing'

  // JPA
  implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

  implementation 'org.postgresql:postgresql'
  implementation 'com.zaxxer:HikariCP:4.0.3'
  testImplementation 'com.h2database:h2:2.1.214'

  implementation 'org.liquibase:liquibase-core'

  implementation 'org.apache.tinkerpop:gremlin-core:3.6.4'
  implementation 'org.apache.tinkerpop:gremlin-driver:3.6.4'

  implementation 'com.azure.spring:spring-cloud-azure-dependencies:5.3.0'
  implementation 'com.azure.spring:spring-cloud-azure-starter-keyvault-secrets:5.3.0'

  implementation 'org.springframework.data:spring-data-redis'
  implementation 'redis.clients:jedis:4.4.3'

  implementation 'io.protostuff:protostuff-core:1.7.4'
  implementation 'io.protostuff:protostuff-runtime:1.7.4'

  implementation group: 'org.apache.httpcomponents', name: 'httpclient', version: '4.5.13'
  implementation 'org.apache.httpcomponents.client5:httpclient5:5.2.1'
  implementation 'io.projectreactor.addons:reactor-extra:3.5.1'


  implementation 'com.opencsv:opencsv:3.7'

  implementation 'com.azure:azure-storage-blob:12.23.0'

  implementation 'commons-io:commons-io:2.16.0'
  implementation 'commons-validator:commons-validator:1.7'

  implementation 'org.apache.commons:commons-compress:1.24.0'
  implementation 'org.apache.poi:poi:5.2.4'
  implementation 'org.apache.poi:poi-ooxml:5.2.4'

}

ext {
  set('snippetsDir', file("build/generated-snippets"))
}

asciidoctor {
  inputs.dir snippetsDir
  dependsOn test
}

test {
  useJUnitPlatform()
  jacoco {
      destinationFile = file("$buildDir/jacoco/jacocoTest.exec")
      classDumpDir = file("$buildDir/jacoco/classpathdumps")
  }
  exclude '**/Api**'
}

jacoco {
    toolVersion = "0.8.8"
    reportsDir = file("$buildDir/reports/jacoco")
}
jacocoTestReport {
    reports {
        xml.enabled true
        csv.enabled false
        html.destination file("${buildDir}/jacocoHtml")
    }
    afterEvaluate {
      classDirectories.setFrom(files(classDirectories.files.collect {
          fileTree(dir: it, includes: [

          ], exclude: [

          ])
      }))
  }
}

Solution

  • That this ever worked for you in some Spring versions - I did not double-check that it really did - was pure luck, because you are making a mistake:

    1. You use native AspectJ post-compile weaving via Freefair. Why you are doing that, I am not sure. Maybe, you want to enable aspects triggering for self-invocation within Spring components. Maybe, your real use case even needs to apply aspects to classes which are not Spring-managed at all.

    2. On top of that, your Spring configuration explicitly instantiates the native aspect as a Spring @Bean, probably to somehow trick Spring into supporting auto-wiring.

    By doing these both things, your aspect now in a way exists twice: as a native AspectJ aspect and as a Spring AOP aspect or at least a Spring component. This is wrong.

    The correct way to enable @Inject or @Autowired for non-Spring objects is described in the Spring manual, chapter "Using AspectJ to Dependency Inject Domain Objects with Spring": You use @Configurable from spring-aspects, which needs to be activated via @EnableSpringConfigured and can be applied either by load-time weaving or by post-compile weaving. As you are using the latter via Freefair already anyway, we will stick with that.

    package com.earny.test.config;
    
    import org.springframework.context.annotation.ComponentScan;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.context.annotation.aspectj.EnableSpringConfigured;
    
    @Configuration
    @ComponentScan("com.earny.test.monitor")
    // Enable @Configurable aspect to inject Spring beans into non-managed objects
    @EnableSpringConfigured
    public class AspectConfig {}
    
    package com.earny.test.monitor.aop;
    
    import com.earny.test.monitor.annotation.MonitorOnce;
    import com.earny.test.monitor.annotation.MonitorTwice;
    import com.earny.test.monitor.service.MonitorService;
    import lombok.extern.log4j.Log4j2;
    import org.aspectj.lang.JoinPoint;
    import org.aspectj.lang.annotation.AfterReturning;
    import org.aspectj.lang.annotation.Aspect;
    import org.aspectj.lang.annotation.Pointcut;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.beans.factory.annotation.Configurable;
    
    @Aspect
    @Log4j2
    @Configurable  // Inject Spring beans into non-managed objects
    public class MonitorMethodAspect {
    
      @Autowired MonitorService monitorService;
    
      @Pointcut("@annotation(monitor)")
      public void callMonitorOnce(MonitorOnce monitor) {}
    
      @Pointcut("@annotation(monitor)")
      public void callMonitorTwice(MonitorTwice monitor) {}
    
      @AfterReturning(
          value = "callMonitorOnce(monitor) && execution(* *(..)) && args(.., a, b)",
          returning = "result")
      public void afterReturn1(JoinPoint joinPoint, MonitorOnce monitor, int a, int b, String result) {
        log.info("afterReturn1 joinPoint: " + joinPoint + " result:" + result);
        if (monitorService == null)
          throw new RuntimeException("@Autowired MonitorService is not working unexpectedly");
      }
    
      @AfterReturning(
          value = "callMonitorTwice(monitor) && execution(* *(..)) && args(.., a, b)",
          returning = "result")
      public void afterReturn2(JoinPoint joinPoint, MonitorTwice monitor, int a, int b, String result) {
        log.info("afterReturn2 joinPoint: " + joinPoint + " result:" + result);
        if (monitorService == null)
          throw new RuntimeException("@Autowired MonitorService is not working unexpectedly");
      }
    }
    

    Why use @Configurable here? Because a native AspectJ aspect is not Spring-managed and also should not be tricked into being so. But still, we can inject Spring components this way.

    BTW, the runtime exception in case of monitorService == null is just to prove that it works. You can remove those two pairs of lines from your aspect advice methods after you successfully tested it.

    Last but not least, you also need this in your Gradle build configuration, telling Freefair to use spring-aspects as an aspect library for post-compile weaving:

    dependencies {
      // ...
      // Enable @Configurable aspect to inject Spring beans into non-managed objects
      aspect 'org.springframework:spring-aspects'
    }
    

    Running the project like this, the log output should look like this:

    -----------------------------------Test Start-----------------------------------
    exec returnOnce function
    afterReturn1 joinPoint: execution(String com.earny.test.controller.TestController.returnOnce(int, int)) result:returnOnce
    exec returnTwice function
    afterReturn2 joinPoint: execution(String com.earny.test.service.impl.TestServiceImpl.returnTwice(int, int)) result:returnTwice
    -----------------------------------Test End-----------------------------------
    

    For your convenience, I also created a GitHub pull request.