Search code examples
springaopspring-aop

Spring AOP - passing arguments between annotated methods


i've written a utility to monitor individual business transactions. For example, Alice calls a method which calls more methods and i want info on just Alice's call, separate from Bob's call to the same method.

Right now the entry point creates a Transaction object and it's passed as an argument to each method:

class Example {
  public Item getOrderEntryPoint(int orderId) {
    Transaction transaction = transactionManager.create();
    transaction.trace("getOrderEntryPoint");
    Order order = getOrder(orderId, transaction);
    transaction.stop();
    logger.info(transaction);
    return item;
  }

  private Order getOrder(int orderId, Transaction t) {
    t.trace("getOrder");
    Order order = getItems(itemId, t);
    t.addStat("number of items", order.getItems().size());
    for (Item item : order.getItems()) {
      SpecialOffer offer = getSpecialOffer(item, t);
      if (null != offer) {
        t.incrementStat("offers", 1);
      }
    }
    t.stop();
    return order;
  }

  private SpecialOffer getSpecialOffer(Item item, Transaction t) {
    t.trace("getSpecialOffer(" + item.id + ")", TraceCategory.Database);
    return offerRepository.getByItem(item);
    t.stop();
  }
}

This will print to the log something like:

Transaction started by Alice at 10:42
Statistics:
    number of items : 3
    offers          : 1
Category Timings (longest first):
    DB   : 2s 903ms
    code : 187ms
Timings (longest first):
    getSpecialOffer(1013) : 626ms
    getItems              : 594ms
Trace:
  getOrderEntryPoint (7ms)
      getOrder (594ms)
          getSpecialOffer(911) (90ms)
          getSpecialOffer(1013) (626ms)
          getSpecialOffer(2942) (113ms)

It works great but passing the transaction object around is ugly. Someone suggested AOP but i don't see how to pass the transaction created in the first method to all the other methods.

The Transaction object is pretty simple:

public class Transaction {
  private String uuid = UUID.createRandom();
  private List<TraceEvent> events = new ArrayList<>();
  private Map<String,Int> stats = new HashMap<>();
}

class TraceEvent {
  private String name;
  private long   durationInMs;
}

The app that uses it is a Web app, and this multi-threaded, but the individual transactions are on a single thread - no multi-threading, async code, competition for resources, etc.

My attempt at an annotation:

@Around("execution(* *(..)) && @annotation(Trace)")
public Object around(ProceedingJoinPoint point) {
  String methodName = MethodSignature.class.cast(point.getSignature()).getMethod().getName();
  //--- Where do i get this call's instance of TRANSACTION from? 
  if (null == transaction) {
    transaction = TransactionManager.createTransaction();
  }
  transaction.trace(methodName);
  Object result = point.proceed();
  transaction.stop();
  return result;

Solution

  • Introduction

    Unfortunately, your pseudo code does not compile. It contains several syntactical and logical errors. Furthermore, some helper classes are missing. If I did not have spare time today and was looking for a puzzle to solve, I would not have bothered making my own MCVE out of it, because that would actually have been your job. Please do read the MCVE article and learn to create one next time, otherwise you will not get a lot of qualified help here. This was your free shot because you are new on SO.

    Original situation: passing through transaction objects in method calls

    Application helper classes:

    package de.scrum_master.app;
    
    public class Item {
      private int id;
    
      public Item(int id) {
        this.id = id;
      }
    
      public int getId() {
        return id;
      }
    
      @Override
      public String toString() {
        return "Item[id=" + id + "]";
      }
    }
    
    package de.scrum_master.app;
    
    public class SpecialOffer {}
    
    package de.scrum_master.app;
    
    public class OfferRepository {
      public SpecialOffer getByItem(Item item) {
        if (item.getId() < 30)
          return new SpecialOffer();
        return null;
      }
    }
    
    package de.scrum_master.app;
    
    import java.util.ArrayList;
    import java.util.List;
    
    public class Order {
      private int id;
    
      public Order(int id) {
        this.id = id;
      }
    
      public List<Item> getItems() {
        List<Item> items = new ArrayList<>();
        int offset = id == 12345 ? 0 : 1;
        items.add(new Item(11 + offset, this));
        items.add(new Item(22 + offset, this));
        items.add(new Item(33 + offset, this));
        return items;
      }
    }
    

    Trace classes:

    package de.scrum_master.trace;
    
    public enum TraceCategory {
      Code, Database
    }
    
    package de.scrum_master.trace;
    
    class TraceEvent {
      private String name;
      private TraceCategory category;
      private long durationInMs;
      private boolean finished = false;
    
      public TraceEvent(String name, TraceCategory category, long startTime) {
        this.name = name;
        this.category = category;
        this.durationInMs = startTime;
      }
    
      public long getDurationInMs() {
        return durationInMs;
      }
    
      public void setDurationInMs(long durationInMs) {
        this.durationInMs = durationInMs;
      }
    
      public boolean isFinished() {
        return finished;
      }
    
      public void setFinished(boolean finished) {
        this.finished = finished;
      }
    
      @Override
      public String toString() {
        return "TraceEvent[name=" + name + ", category=" + category +
          ", durationInMs=" + durationInMs + ", finished=" + finished + "]";
      }
    }
    

    Transaction classes:

    Here I tried to mimic your own Transaction class with as few as possible changes, but there was a lot I had to add and modify in order to emulate a simplified version of your trace output. This is not thread-safe and the way I am locating the last unfinished TraceEvent is not nice and only works cleanly if there are not exceptions. But you get the idea, I hope. The point is to just make it basically work and subsequently get log output similar to your example. If this was originally my code, I would have solved it differently.

    package de.scrum_master.trace;
    
    import java.util.ArrayList;
    import java.util.HashMap;
    import java.util.List;
    import java.util.Map;
    import java.util.Map.Entry;
    import java.util.UUID;
    
    public class Transaction {
      private String uuid = UUID.randomUUID().toString();
      private List<TraceEvent> events = new ArrayList<>();
      private Map<String, Integer> stats = new HashMap<>();
    
      public void trace(String message) {
        trace(message, TraceCategory.Code);
      }
    
      public void trace(String message, TraceCategory category) {
        events.add(new TraceEvent(message, category, System.currentTimeMillis()));
      }
    
      public void stop() {
        TraceEvent event = getLastUnfinishedEvent();
        event.setDurationInMs(System.currentTimeMillis() - event.getDurationInMs());
        event.setFinished(true);
      }
    
      private TraceEvent getLastUnfinishedEvent() {
        return events
          .stream()
          .filter(event -> !event.isFinished())
          .reduce((first, second) -> second)
          .orElse(null);
      }
    
      public void addStat(String text, int size) {
        stats.put(text, size);
      }
    
      public void incrementStat(String text, int increment) {
        Integer currentCount = stats.get(text);
        if (currentCount == null)
          currentCount = 0;
        stats.put(text, currentCount + increment);
      }
    
      @Override
      public String toString() {
        return "Transaction {" +
          toStringUUID() +
          toStringStats() +
          toStringEvents() +
          "\n}\n";
      }
    
      private String toStringUUID() {
        return "\n  uuid = " + uuid;
      }
    
      private String toStringStats() {
        String result = "\n  stats = {";
        for (Entry<String, Integer> statEntry : stats.entrySet())
          result += "\n    " + statEntry;
        return result + "\n  }";
      }
    
      private String toStringEvents() {
        String result = "\n  events = {";
        for (TraceEvent event : events)
          result += "\n    " + event;
        return result + "\n  }";
      }
    }
    
    package de.scrum_master.trace;
    
    public class TransactionManager {
      public Transaction create() {
        return new Transaction();
      }
    }
    

    Example driver application:

    package de.scrum_master.app;
    
    import de.scrum_master.trace.TraceCategory;
    import de.scrum_master.trace.Transaction;
    import de.scrum_master.trace.TransactionManager;
    
    public class Example {
      private TransactionManager transactionManager = new TransactionManager();
      private OfferRepository offerRepository = new OfferRepository();
    
      public Order getOrderEntryPoint(int orderId) {
        Transaction transaction = transactionManager.create();
        transaction.trace("getOrderEntryPoint");
        sleep(100);
        Order order = getOrder(orderId, transaction);
        transaction.stop();
        System.out.println(transaction);
        return order;
      }
    
      private Order getOrder(int orderId, Transaction t) {
        t.trace("getOrder");
        sleep(200);
        Order order = new Order(orderId);
        t.addStat("number of items", order.getItems().size());
        for (Item item : order.getItems()) {
          SpecialOffer offer = getSpecialOffer(item, t);
          if (null != offer)
            t.incrementStat("special offers", 1);
        }
        t.stop();
        return order;
      }
    
      private SpecialOffer getSpecialOffer(Item item, Transaction t) {
        t.trace("getSpecialOffer(" + item.getId() + ")", TraceCategory.Database);
        sleep(50);
        SpecialOffer specialOffer = offerRepository.getByItem(item);
        t.stop();
        return specialOffer;
      }
    
      private void sleep(long millis) {
        try {
          Thread.sleep(millis);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      }
    
      public static void main(String[] args) {
        new Example().getOrderEntryPoint(12345);
        new Example().getOrderEntryPoint(23456);
      }
    }
    

    If you run this code, the output is as follows:

    Transaction {
      uuid = 62ec9739-bd32-4a56-b6b3-a8a13624961a
      stats = {
        special offers=2
        number of items=3
      }
      events = {
        TraceEvent[name=getOrderEntryPoint, category=Code, durationInMs=561, finished=true]
        TraceEvent[name=getOrder, category=Code, durationInMs=451, finished=true]
        TraceEvent[name=getSpecialOffer(11), category=Database, durationInMs=117, finished=true]
        TraceEvent[name=getSpecialOffer(22), category=Database, durationInMs=69, finished=true]
        TraceEvent[name=getSpecialOffer(33), category=Database, durationInMs=63, finished=true]
      }
    }
    
    Transaction {
      uuid = a420cd70-96e5-44c4-a0a4-87e421d05e87
      stats = {
        special offers=2
        number of items=3
      }
      events = {
        TraceEvent[name=getOrderEntryPoint, category=Code, durationInMs=469, finished=true]
        TraceEvent[name=getOrder, category=Code, durationInMs=369, finished=true]
        TraceEvent[name=getSpecialOffer(12), category=Database, durationInMs=53, finished=true]
        TraceEvent[name=getSpecialOffer(23), category=Database, durationInMs=63, finished=true]
        TraceEvent[name=getSpecialOffer(34), category=Database, durationInMs=53, finished=true]
      }
    }
    

    AOP refactoring

    Preface

    Please note that I am using AspectJ here because two things about your code would never work with Spring AOP because it works with a delegation pattern based on dynamic proxies:

    • self-invocation (internally calling a method of the same class or super-class)
    • intercepting private methods

    Because of these Spring AOP limitations I advise you to either refactor your code so as to avoid the two issues above or to configure your Spring applications to use full AspectJ via LTW (load-time weaving) instead.

    As you noticed, my sample code does not use Spring at all because AspectJ is completely independent of Spring and works with any Java application (or other JVM languages, too).

    Refactoring idea

    Now what should you do in order to get rid of passing around tracing information (Transaction objects), polluting your core application code and tangling it with trace calls?

    • You extract transaction tracing into an aspect taking care of all trace(..) and stop() calls.
    • Unfortunately your Transaction class contains different types of information and does different things, so you cannot completely get rid of context information about how to trace for each affected method. But at least you can extract that context information from the method bodies and transform it into a declarative form using annotations with parameters.
    • These annotations can be targeted by an aspect taking care of handling transaction tracing.

    Added and updated code, iteration 1

    Annotations related to transaction tracing:

    package de.scrum_master.trace;
    
    import static java.lang.annotation.ElementType.METHOD;
    import static java.lang.annotation.RetentionPolicy.RUNTIME;
    
    import java.lang.annotation.Retention;
    import java.lang.annotation.Target;
    
    @Retention(RUNTIME)
    @Target(METHOD)
    public @interface TransactionEntryPoint {}
    
    package de.scrum_master.trace;
    
    import static java.lang.annotation.ElementType.METHOD;
    import static java.lang.annotation.RetentionPolicy.RUNTIME;
    
    import java.lang.annotation.Retention;
    import java.lang.annotation.Target;
    
    @Retention(RUNTIME)
    @Target(METHOD)
    public @interface TransactionTrace {
      String message() default "__METHOD_NAME__";
      TraceCategory category() default TraceCategory.Code;
      String addStat() default "";
      String incrementStat() default "";
    }
    

    Refactored application classes with annotations:

    package de.scrum_master.app;
    
    import java.util.ArrayList;
    import java.util.List;
    
    import de.scrum_master.trace.TransactionTrace;
    
    public class Order {
      private int id;
    
      public Order(int id) {
        this.id = id;
      }
    
      @TransactionTrace(message = "", addStat = "number of items")
      public List<Item> getItems() {
        List<Item> items = new ArrayList<>();
        int offset = id == 12345 ? 0 : 1;
        items.add(new Item(11 + offset));
        items.add(new Item(22 + offset));
        items.add(new Item(33 + offset));
        return items;
      }
    }
    

    Nothing much here, only added an annotation to getItems(). But the sample application class changes massively, getting much cleaner and simpler:

    package de.scrum_master.app;
    
    import de.scrum_master.trace.TraceCategory;
    import de.scrum_master.trace.TransactionEntryPoint;
    import de.scrum_master.trace.TransactionTrace;
    
    public class Example {
      private OfferRepository offerRepository = new OfferRepository();
    
      @TransactionEntryPoint
      @TransactionTrace
      public Order getOrderEntryPoint(int orderId) {
        sleep(100);
        Order order = getOrder(orderId);
        return order;
      }
    
      @TransactionTrace
      private Order getOrder(int orderId) {
        sleep(200);
        Order order = new Order(orderId);
        for (Item item : order.getItems()) {
          SpecialOffer offer = getSpecialOffer(item);
          // Do something with special offers
        }
        return order;
      }
    
      @TransactionTrace(category = TraceCategory.Database, incrementStat = "specialOffers")
      private SpecialOffer getSpecialOffer(Item item) {
        sleep(50);
        SpecialOffer specialOffer = offerRepository.getByItem(item);
        return specialOffer;
      }
    
      private void sleep(long millis) {
        try {
          Thread.sleep(millis);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      }
    
      public static void main(String[] args) {
        new Example().getOrderEntryPoint(12345);
        new Example().getOrderEntryPoint(23456);
      }
    }
    

    See? Except for a few annotations there is nothing left of the transaction tracing logic, the application code only takes care of its core concern. If you also remove the sleep() method which only makes the application slower for demonstration purposes (because we want some nice statistics with measured times >0 ms), the class gets even more compact.

    But of course we need to put the transaction tracing logic somewhere, more precisely modularise it into an AspectJ aspect:

    Transaction tracing aspect:

    package de.scrum_master.trace;
    
    import java.lang.reflect.Array;
    import java.util.Arrays;
    import java.util.Collection;
    import java.util.stream.Collectors;
    
    import org.aspectj.lang.JoinPoint;
    import org.aspectj.lang.ProceedingJoinPoint;
    import org.aspectj.lang.annotation.After;
    import org.aspectj.lang.annotation.Around;
    import org.aspectj.lang.annotation.Aspect;
    import org.aspectj.lang.annotation.Pointcut;
    import org.aspectj.lang.reflect.MethodSignature;
    
    @Aspect("percflow(entryPoint())")
    public class TransactionTraceAspect {
      private static TransactionManager transactionManager = new TransactionManager();
    
      private Transaction transaction = transactionManager.create();
    
      @Pointcut("execution(* *(..)) && @annotation(de.scrum_master.trace.TransactionEntryPoint)")
      private static void entryPoint() {}
    
      @Around("execution(* *(..)) && @annotation(transactionTrace)")
      public Object doTrace(ProceedingJoinPoint joinPoint, TransactionTrace transactionTrace) throws Throwable {
        preTrace(transactionTrace, joinPoint);
        Object result = joinPoint.proceed();
        postTrace(transactionTrace);
        addStat(transactionTrace, result);
        incrementStat(transactionTrace, result);
        return result;
      }
    
      private void preTrace(TransactionTrace transactionTrace, ProceedingJoinPoint joinPoint) {
        String traceMessage = transactionTrace.message();
        if ("".equals(traceMessage))
          return;
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        if ("__METHOD_NAME__".equals(traceMessage)) {
          traceMessage = signature.getName() + "(";
          traceMessage += Arrays.stream(joinPoint.getArgs()).map(arg -> arg.toString()).collect(Collectors.joining(", "));
          traceMessage += ")";
        }
        transaction.trace(traceMessage, transactionTrace.category());
      }
    
      private void postTrace(TransactionTrace transactionTrace) {
        if ("".equals(transactionTrace.message()))
          return;
        transaction.stop();
      }
    
      private void addStat(TransactionTrace transactionTrace, Object result) {
        if ("".equals(transactionTrace.addStat()) || result == null)
          return;
        if (result instanceof Collection)
          transaction.addStat(transactionTrace.addStat(), ((Collection<?>) result).size());
        else if (result.getClass().isArray())
          transaction.addStat(transactionTrace.addStat(), Array.getLength(result));
      }
    
      private void incrementStat(TransactionTrace transactionTrace, Object result) {
        if ("".equals(transactionTrace.incrementStat()) || result == null)
          return;
        transaction.incrementStat(transactionTrace.incrementStat(), 1);
      }
    
      @After("entryPoint()")
      public void logFinishedTransaction(JoinPoint joinPoint) {
        System.out.println(transaction);
      }
    }
    

    Let me explain what this aspect does:

    • @Pointcut(..) entryPoint() says: Find me all methods in the code annotated by @TransactionEntryPoint. This pointcut is used in two places:

      1. @Aspect("percflow(entryPoint())") says: Create one aspect instance for each control flow beginning at a transaction entry point.

      2. @After("entryPoint()") logFinishedTransaction(..) says: Execute this advice (AOP terminology for a method linked to a pointcut) after an entry point methods is finished. The corresponding method just prints the transaction statistics just like in the original code at the end of Example.getOrderEntryPoint(..).

    • @Around("execution(* *(..)) && @annotation(transactionTrace)") doTrace(..)says: Wrap methods annotated by TransactionTrace and do the following (method body):

      • add new trace element and start measuring time
      • execute original (wrapped) method and store result
      • update trace element with measured time
      • add one type of statistics (optional)
      • increment another type of statistics (optional)
      • return wrapped method's result to its caller
    • The private methods are just helpers for the @Around advice.

    The console log when running the updated Example class and active AspectJ is:

    Transaction {
      uuid = 4529d325-c604-441d-8997-45ca659abb14
      stats = {
        specialOffers=2
        number of items=3
      }
      events = {
        TraceEvent[name=getOrderEntryPoint(12345), category=Code, durationInMs=468, finished=true]
        TraceEvent[name=getOrder(12345), category=Code, durationInMs=366, finished=true]
        TraceEvent[name=getSpecialOffer(Item[id=11]), category=Database, durationInMs=59, finished=true]
        TraceEvent[name=getSpecialOffer(Item[id=22]), category=Database, durationInMs=50, finished=true]
        TraceEvent[name=getSpecialOffer(Item[id=33]), category=Database, durationInMs=51, finished=true]
      }
    }
    
    Transaction {
      uuid = ef76a996-8621-478b-a376-e9f7a729a501
      stats = {
        specialOffers=2
        number of items=3
      }
      events = {
        TraceEvent[name=getOrderEntryPoint(23456), category=Code, durationInMs=452, finished=true]
        TraceEvent[name=getOrder(23456), category=Code, durationInMs=351, finished=true]
        TraceEvent[name=getSpecialOffer(Item[id=12]), category=Database, durationInMs=50, finished=true]
        TraceEvent[name=getSpecialOffer(Item[id=23]), category=Database, durationInMs=50, finished=true]
        TraceEvent[name=getSpecialOffer(Item[id=34]), category=Database, durationInMs=50, finished=true]
      }
    }
    

    You see, it looks almost identical to the original application.

    Idea for further simplification, iteration 2

    When reading method Example.getOrder(int orderId) I was wondering why you are calling order.getItems(), looping over it and calling getSpecialOffer(item) inside the loop. In your sample code you do not use the results for anything other than updating the transaction trace object. I am assuming that in your real code you do something with the order and with the special offers in that method.

    But just in case you really do not need those calls inside that method, I suggest

    • you factor the calls out right into the aspect, getting rid of the TransactionTrace annotation parameters String addStat() and String incrementStat().
    • The Example code would get even simpler and
    • the annotation @TransactionTrace(message = "", addStat = "number of items") in class would go away, too.

    I am leaving this refactoring to you if you think it makes sense.