Spring Framework

Programación orientada a aspectos con Spring AOP y AspectJ

Intermedio9 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

Logging, medición de tiempos, reintentos, comprobaciones de seguridad y transacciones atraviesan todas las capas de una aplicación. Spring AOP te permite escribir cada una de ellas una sola vez como un aspecto y aplicarla de forma declarativa. Al terminar esta guía sabrás escribir aspectos con @Aspect, seleccionar métodos con precisión mediante expresiones de pointcut, elegir el tipo de advice adecuado y, sobre todo, explicar por qué el modelo basado en proxies hace que algunas llamadas nunca se intercepten.

Lo que aprenderás

  • Definir aspecto, join point, pointcut, advice y weaving, y relacionarlos con el modelo de proxies de Spring
  • Escribir advices @Around, @Before, @AfterReturning y @AfterThrowing con @Aspect
  • Componer expresiones de pointcut con los designadores execution, within, @annotation, args y bean
  • Explicar la diferencia entre los proxies dinámicos de JDK y los proxies CGLIB por subclase, y cuándo se usa cada uno
  • Diagnosticar advices que no se ejecutan, sin error alguno, por autoinvocación, métodos final o instanciación fuera de Spring

Hay comportamientos que se niegan a quedarse en un solo sitio. Medir cuánto tarda un método, comprobar un permiso, reintentar tras un fallo transitorio, abrir una transacción: cada uno de ellos hace falta en decenas de clases de todas las capas, y copiar y pegar el mismo try/finally en cada una dispersa una única decisión por todo el código. La programación orientada a aspectos (AOP) pone nombre a este problema —las preocupaciones transversales (cross-cutting concerns)— y ofrece una forma de escribir el comportamiento una sola vez y declarar dónde se aplica. @Transactional, @Cacheable, @Async, @PreAuthorize y @Retryable son todos AOP por debajo, así que entender el mecanismo marca la diferencia entre usar esas anotaciones y saber depurarlas. Esta guía se centra en Spring Framework 6.x.

Vocabulario

La AOP tiene un vocabulario reducido y preciso, tomado de AspectJ, que Spring usa tal cual.

TérminoSignificadoEn Spring AOP
Join pointUn punto de la ejecución del programa donde se podría añadir comportamientoSiempre la ejecución de un método sobre un bean de Spring
PointcutUn predicado que selecciona join pointsUna expresión AspectJ como execution(* com.shop.service.*.*(..))
AdviceEl comportamiento que se ejecuta en un join pointUn método de un aspecto anotado con @Before, @Around, etc.
AspectoUn módulo que agrupa pointcuts y advicesUn bean anotado con @Aspect
Destino (target)El objeto que recibe el adviceTu bean
ProxyEl objeto creado para aplicar el adviceUn proxy JDK o CGLIB que envuelve al destino
WeavingEnlazar los aspectos con el códigoEn tiempo de ejecución, creando proxies durante el post-procesamiento de los beans

La restricción de la primera fila es la que importa: Spring AOP solo ve ejecuciones de métodos sobre beans que él mismo ha creado. Los accesos a campos, los constructores, los métodos static y los objetos que construyes con new le resultan invisibles.

Tu primer aspecto

Activa el mecanismo con @EnableAspectJAutoProxy en una clase de configuración, añade spring-aop y aspectjweaver al classpath (este último aporta el parser de pointcuts aunque no haya ninguna compilación con AspectJ) y declara un aspecto como un bean corriente:

@Aspect
@Component
public class TimingAspect {

    private static final Logger log = LoggerFactory.getLogger(TimingAspect.class);

    @Around("execution(public * com.example.shop.service..*(..))")
    public Object time(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.nanoTime();
        try {
            return pjp.proceed();                       // ejecuta el método real
        } finally {
            long micros = (System.nanoTime() - start) / 1_000;
            log.debug("{} took {} µs", pjp.getSignature().toShortString(), micros);
        }
    }
}

Al arrancar, el auto-proxy creator inspecciona cada bean; si algún pointcut de cualquier aspecto coincide con al menos uno de sus métodos, el bean se sustituye en el contenedor por un proxy. Todos los demás beans que dependen de él reciben el proxy. Cuando se llama a OrderService.confirm() sobre el proxy, este ejecuta la cadena de advices correspondiente, y proceed() invoca el método real sobre el destino envuelto.

Tipos de advice

Existen cinco tipos de advice; elige el menos potente que resuelva el problema.

  • @Before se ejecuta antes del método. No puede impedir la llamada salvo lanzando una excepción.
  • @AfterReturning se ejecuta tras un retorno normal y puede leer el valor devuelto con el atributo returning.
  • @AfterThrowing se ejecuta cuando el método lanza una excepción y puede enlazarla con throwing. No se traga la excepción.
  • @After se ejecuta tanto en la salida normal como en la excepcional, igual que finally.
  • @Around envuelve la llamada. Recibe un ProceedingJoinPoint, decide si llama a proceed() y cuántas veces, puede cambiar los argumentos y el valor devuelto, y puede capturar excepciones. Es el único advice capaz de implementar reintentos, caché o demarcación de transacciones.
@Aspect
@Component
public class AuditAspect {

    @Before("@annotation(audited) && args(id, ..)")     // enlaza la anotación y el primer argumento
    public void logIntent(JoinPoint jp, Audited audited, long id) {
        Audit.record(audited.value(), jp.getSignature().getName(), id);
    }

    @AfterThrowing(pointcut = "within(com.example.shop.payment..*)", throwing = "ex")
    public void alertOnPaymentFailure(PaymentException ex) {
        Alerts.raise("payment failure", ex);
    }

    @AfterReturning(pointcut = "execution(* com.example.shop.service.OrderService.place(..))",
                    returning = "order")
    public void indexOrder(Order order) {
        SearchIndex.add(order);
    }
}

Un advice @Around debe devolver el resultado de proceed() (o un sustituto deliberado) y declarar throws Throwable. Olvidar el return convierte cada método interceptado en uno que devuelve null: un bug que desconcierta cuando lo ves y es fácil de cometer.

Expresiones de pointcut

Spring admite un subconjunto de los designadores de pointcut de AspectJ. Estos son los que usarás:

DesignadorSeleccionaEjemplo
execution(…)Un patrón de firma de métodoexecution(public * *..OrderService.*(..))
within(…)Todos los métodos de los tipos de un paquete o clasewithin(com.example.shop.service..*)
@within(…)Tipos que llevan una anotación@within(org.springframework.stereotype.Service)
@annotation(…)Métodos que llevan una anotación@annotation(com.example.Audited)
args(…)Tipos de los argumentos en tiempo de ejecuciónargs(java.io.Serializable, ..)
bean(…)Patrón de nombre de bean de Springbean(*Repository)
this(…) / target(…)Tipo del proxy / del destinotarget(com.example.Refundable)

El patrón de execution se lee modifiers? return-type declaring-type?.method-name(params) throws?. En él, * coincide con un solo elemento (cualquier tipo de retorno, cualquier nombre de método, un único parámetro), .. en una lista de parámetros coincide con cualquier número de parámetros, y .. en un nombre de paquete incluye los subpaquetes.

Los pointcuts reutilizables se declaran como métodos vacíos anotados con @Pointcut y se combinan con &&, || y !:

@Aspect
public class SystemArchitecture {

    @Pointcut("within(com.example.shop..web..*)")
    public void webLayer() {}

    @Pointcut("within(com.example.shop..service..*)")
    public void serviceLayer() {}

    @Pointcut("execution(* com.example.shop..repository.*.*(..))")
    public void dataAccessOperation() {}

    @Pointcut("serviceLayer() && @annotation(org.springframework.transaction.annotation.Transactional)")
    public void transactionalService() {}
}

Los demás aspectos los referencian por su nombre completamente cualificado: @Around("com.example.shop.SystemArchitecture.serviceLayer()"). Centralizar los pointcuts en una sola clase convierte la pregunta «¿qué métodos se interceptan?» en la lectura de un único archivo, en lugar de un ejercicio de arqueología.

Prefiere los pointcuts basados en anotaciones (@annotation, @within) para comportamientos que un desarrollador deba activar explícitamente, como la auditoría o los reintentos; prefiere los pointcuts basados en paquetes (within, execution) para reglas de arquitectura que se aplican a toda una capa.

Proxies: cómo funciona el truco

Spring crea uno de estos dos tipos de proxy.

Un proxy dinámico de JDK es una clase generada en tiempo de ejecución que implementa las interfaces del destino y encamina cada llamada a través de un InvocationHandler. Solo puede usarse cuando existe una interfaz, y solo puede inyectarse por el tipo de la interfaz: un campo de tipo OrderServiceImpl no puede recibir un proxy JDK de OrderService, y el contenedor fallará con un error «expected single matching bean» o BeanNotOfRequiredTypeException.

Un proxy CGLIB es una subclase generada de la clase destino que sobrescribe sus métodos public y protected. No necesita interfaz, puede inyectarse por el tipo concreto y es la opción por defecto en Spring Boot. Sus limitaciones son las de la herencia: ni la clase ni los métodos interceptados pueden ser final, y el proxy tiene que poder llamar a un constructor; desde Spring 4 se usa Objenesis para saltarse la invocación del constructor, así que una clase sin constructor sin argumentos también funciona.

@Configuration
@EnableAspectJAutoProxy(proxyTargetClass = true)   // siempre CGLIB, como hace Boot
public class AopConfig { }

Sea cual sea el tipo creado, el proxy guarda una referencia al destino real y le delega la llamada después de ejecutar la cadena de advices. De ahí se derivan tres consecuencias, que explican casi todas las preguntas del estilo «la AOP no funciona».

La autoinvocación no se intercepta

@Service
public class InvoiceService {

    public void closeMonth() {
        for (var account : accounts()) {
            issue(account);            // this.issue(): llamada directa, se salta el proxy
        }
    }

    @Transactional
    public void issue(Account account) { … }   // NO es transaccional cuando se llama desde closeMonth
}

El this dentro del destino es el objeto sin envolver, no el proxy. closeMonth llama a issue sobre ese objeto, así que el interceptor de transacciones nunca ve la llamada. La solución que mantiene un diseño honesto es mover issue a un bean aparte. La alternativa es inyectar el propio proxy del bean (ObjectProvider<InvoiceService> self) y llamar a self.getObject().issue(account); es legítimo, pero conviene comentarlo, porque quien lea el código después no se lo esperará.

Solo se crean proxies para los beans que crea Spring

Un objeto creado con new, o devuelto por un método factory que Spring no gestiona, no tiene proxy y no recibe ningún advice. Los beans prototype obtenidos del contenedor sí tienen proxy; los objetos que ellos mismos crean, no.

El tipo en tiempo de ejecución cambia

service.getClass() devuelve la clase del proxy. instanceof OrderService sigue funcionando con ambos tipos de proxy cuando OrderService es una interfaz; con CGLIB, instanceof OrderServiceImpl también funciona, porque el proxy es una subclase. Usa AopUtils.getTargetClass(bean) o AopProxyUtils.ultimateTargetClass en esos raros fragmentos de código de tipo framework que necesitan la clase real.

Orden y aspectos múltiples

Cuando varios aspectos coinciden con un mismo join point, su orden no está definido a menos que lo especifiques con @Order en las clases de aspecto (el valor más bajo se ejecuta primero, es decir, en la capa más externa). Los interceptores propios de Spring también tienen Ordered.LOWEST_PRECEDENCE por defecto —el orden del interceptor de transacciones se configura en @EnableTransactionManagement—, de modo que un aspecto propio sin @Order no tiene una posición definida respecto a @Transactional. Si tu aspecto de reintentos debe ejecutarse fuera de la transacción para que cada intento obtenga una nueva, dale explícitamente un valor de orden más bajo y deja el interceptor de transacciones con su valor por defecto.

Cuando Spring AOP no basta: weaving con AspectJ

AspectJ completo modifica el bytecode en lugar de envolver objetos. Puede interceptar constructores, lecturas y escrituras de campos, métodos static y private, y clases que Spring nunca instancia. Viene en dos variantes: weaving en tiempo de compilación, con el compilador ajc o un plugin de Maven/Gradle, y weaving en tiempo de carga, con un agente Java (-javaagent:aspectjweaver.jar, o @EnableLoadTimeWeaving de Spring junto con un servidor o agente de instrumentación adecuado). Spring admite ambas y puede inyectar dependencias en aspectos tejidos por AspectJ e incluso en objetos de dominio (@Configurable). El precio es un build más complicado y stack traces que muestran métodos sintéticos. La mayoría de los equipos nunca lo necesitan; cuando creen que sí, el problema real suele ser la autoinvocación, y la mejor solución es una pequeña refactorización.

Errores comunes

Olvidar el return en un advice @Around. Todos los métodos interceptados devuelven null. El compilador no puede detectarlo, porque el advice devuelve Object.

Interceptar con proxies JDK una clase inyectada por su tipo concreto. El arranque falla porque el proxy solo implementa la interfaz. Configura proxyTargetClass = true o inyecta por la interfaz.

Un pointcut que abarca demasiado. execution(* *(..)) intercepta todos los beans, incluidos los internos de Spring, y puede crear proxies de clases que no deben tenerlos. Ancla siempre el pointcut a tu paquete.

Esperar un advice sobre una clase o método final con CGLIB. La subclase no puede sobrescribirlo; Spring registra un warning al arrancar y el método se ejecuta sin advice.

Meter lógica de negocio en los aspectos. Los aspectos son para preocupaciones realmente ortogonales: medición de tiempos, auditoría, transacciones. Las reglas de validación y las decisiones de flujo de trabajo pertenecen al servicio, donde son visibles y se pueden probar.

Probar un aspecto con tests unitarios del destino. Un simple new OrderService() no tiene proxy. Verifica los aspectos con un contexto de test de Spring que cargue el aspecto y el destino, o prueba directamente el método de advice con un ProceedingJoinPoint simulado.

Buenas prácticas

  • Mantén los aspectos pequeños, sin estado y en número reducido. Dos o tres aspectos bien nombrados para logging, auditoría y resiliencia son un número razonable; veinte, un problema de mantenimiento.
  • Declara los pointcuts una sola vez en una clase compartida y referéncialos.
  • Prefiere los pointcuts basados en anotaciones para el comportamiento opcional, de modo que al leer un método se vea qué lo envuelve.
  • Asigna a cada aspecto un @Order explícito cuando más de uno pueda aplicarse al mismo método.
  • Estandariza el uso de proxies CGLIB (proxyTargetClass = true) para que funcione tanto la inyección por tipo concreto como por interfaz, y evita final en los beans de Spring.
  • Usa los aspectos que ya trae Spring (@Transactional, @Cacheable, @Async, @Retryable de Spring Retry) antes de escribir los tuyos para lo mismo.

Ideas clave

  • La AOP separa el comportamiento transversal en aspectos; Spring la implementa con proxies creados en tiempo de ejecución alrededor de los beans, limitados a la ejecución de métodos.
  • Los beans @Aspect contienen pointcuts y advices; @Around es el advice más potente y el único que puede sustituir o repetir la llamada.
  • Las expresiones de pointcut combinan los designadores execution, within, @annotation, args y bean; centralízalas en una sola clase.
  • Los proxies JDK implementan interfaces; los proxies CGLIB son subclases del destino y son la opción por defecto de Spring Boot. Ninguno de los dos puede interceptar métodos final o static.
  • Las llamadas que un bean hace a sus propios métodos se saltan el proxy y no reciben advice: es el bug de AOP más común y afecta por igual a @Transactional y a @Cacheable.
  • El weaving completo de AspectJ elimina esas limitaciones a costa de complicar el build; rara vez es necesario.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Por qué no se aplica mi aspecto cuando un método llama a otro de la misma clase?
Spring AOP funciona envolviendo el bean en un proxy. Solo reciben advice las llamadas que pasan por el proxy. Cuando un método llama a this.otherMethod(), la llamada va directamente al objeto destino y se salta el proxy, así que no se ejecuta ningún advice. Mueve el método afectado a otro bean, inyecta el propio proxy del bean mediante ObjectProvider o pasa al weaving de AspectJ en tiempo de compilación o de carga si de verdad necesitas interceptar la autoinvocación.
¿Puede Spring AOP interceptar métodos private, static o final?
No. Un proxy JDK solo puede implementar métodos de interfaz; un proxy CGLIB sobrescribe métodos en una subclase generada, lo que excluye los métodos y clases final, y los métodos static o private ni siquiera son virtuales. El weaving completo de AspectJ modifica el bytecode directamente y puede interceptarlos todos, a costa de configurar el build o un agente.
¿Spring usa por defecto proxies JDK o CGLIB?
En Spring Framework, @EnableAspectJAutoProxy usa por defecto proxies dinámicos de JDK cuando el bean implementa al menos una interfaz, y CGLIB en caso contrario. Spring Boot invierte ese valor por defecto y usa CGLIB (proxyTargetClass=true) para que los beans puedan inyectarse por su clase concreta. En cualquier caso, puedes forzar proxies basados en clase con proxyTargetClass = true.
¿Spring AOP es lo mismo que AspectJ?
No. Spring AOP reutiliza las anotaciones y el lenguaje de pointcuts de AspectJ, pero los implementa con proxies creados en tiempo de ejecución y limitados a la ejecución de métodos sobre beans de Spring. AspectJ es una extensión completa del lenguaje, con compilador y weaver, capaz de interceptar accesos a campos, constructores, métodos static y objetos que Spring nunca ve. Spring AOP cubre la inmensa mayoría de las necesidades empresariales; recurre a AspectJ solo cuando el modelo de proxies sea realmente insuficiente.