A software system typically starts out with a finite set of well-understood requirements. As most successful systems evolve, however, they take on more and more requirements, incorporating innumerable functional and nonfunctional aspects. In an enterprise environment, you can easily end up adding to this tangle of modules a number of third-party libraries and frameworks, all of which interact and play with each other beneath the surface of the system's day-to-day workings. In effect, given just a few years, the system that started out with a simple, manageable requirement set has become a behemoth: an unruly and obtuse tower of code.
Into this tower steps the Java developer, newly tasked with its daily maintenance and evolution. If you are this developer, then your first task is to gain an intimate understanding of the structure of the system. Understanding the structure will be key to your ability to make enhancements and troubleshoot any problems that, inevitably, will arise. Of course, peering into any unknown system for the first time is easier said than done. In some cases you will be able to ask questions of the original developers, in others you won't. But even with access to the development team some systems are simply too massive to be accessed and understood without mechanical help.
While a number of tools are available to aid you in comprehending complex programs (see Resources) most are expensive, time-consuming to learn, and limited in scope of functionality (that is, you will have no recourse if the tool does not meet your needs). In this article I propose an alternative approach. Aspect-oriented programming is a full-fledged programming paradigm that can be applied to a wide range of programming scenarios, including the comprehension and maintenance of legacy applications.
Please note that this article assumes that you are generally familiar with AOP under AspectJ, and in particular with AspectJ's static and dynamic crosscutting techniques. Although I do provide a brief overview of AOP crosscutting in the next section, please refer to Resources for more detailed information.
Java-based AOP employs a flexible and rich expression language with which you can slice and dice complex applications in a seemingly infinite number of ways. The Java-based AOP syntax is similar enough to the Java language that you should have little trouble grasping it. Once learned, AOP is a programming technique with many applications. In addition to comprehending legacy system internals, you can also use AOP to unobtrusively refactor and enhance such systems. Although you'll work entirely with AspectJ in this article, most of the techniques discussed are portable to other popular Java-based AOP implementations such as AspectWerkz and JBossAOP (see Resources).
Any application is composed of multiple functional and systemic concerns. Functional concerns are relevant to the application's daily use, whereas systemic ones pertain to the overall health and maintenance of the system. For example, the functional concerns of a banking application include account maintenance and allowing debit/credit operations, and its systemic concerns include security, transactions, performance, and audit logging. Even if you develop your applications using the best-of-breed programming methodologies, you will eventually find that its functional and systemic concerns become tangled up with each other in a manner that spans across multiple application modules.
Crosscutting is an AOP technique for ensuring that independent concerns remain modularized but are also flexible enough to be applied at different points throughout an application. The two varieties of crosscutting are static and dynamic. Dynamic crosscutting entails changing the execution behavior of an object by weaving in new behavior at specific points of interest. Static crosscutting lets us alter the very structure of an object by injecting additional methods and/or attributes into it.
The syntax of static crosscutting is quite different from that of dynamic crosscutting. The following terminology applies to dynamic crosscutting:
- A join point is a particular point of execution in a Java
program, such as a method in a class.
- A pointcut is a language-specific construct that denotes
or captures a particular join point.
- An advice is a piece of code (typically, a crosscutting
functionality) to be executed when a specific pointcut is reached.
- An aspect is a construct that defines pointcuts and advices and the mapping between them. Aspects are used by the AOP compiler to weave additional functionality into existing objects at specific execution points.
All of the code demonstrations in this article will utilize dynamic crosscutting. See Resources to learn more about static crosscutting.
To follow the examples in this article you should be familiar with the following features specific to AOP under AspectJ:
- AspectJ provides a compiler/bytecode weaver called
ajcthat compiles AspectJ and Java language files.ajcweaves together aspects as necessary to produce .class files compliant with any Java VM (1.1 or later). - AspectJ supports aspects that specify that a particular join
point should never be reached. If the
ajcprocess determines otherwise, it signals a compile-time warning or error (depending on the aspect).
Application and system analysis
In the following sections you will learn two different mechanisms of application and system analysis using AOP. The first mechanism, which I call static analysis, requires you to do the following:
- Check out from CVS (or whatever code versioning system you use) the
entire application code base into your local area.
- Alter the build file to use the AspectJ compiler (
ajc). - Include the aspect classes at the appropriate places.
- Run a complete build of the system.
The second mechanism, which I call dynamic analysis, requires you to not only build the system in your local area but also run the actual use cases of the system and gather information from runtime reflection into the running system. In the sections that follow I'll go over each mechanism in detail, using code examples to illustrate key concepts.
I'll examine a number of techniques for performing static analysis of a legacy application, applying them to three common maintenance scenarios, as follows:
- Evaluating the impact of an interface change
- Identifying dead or unreachable code
- Instituting loose coupling
Now, let's get started!
Evaluating the impact of an interface change
An object-oriented legacy application or system should consist of a number of modules that expose a well-defined interface to its clients. Suppose, then, that you have been tasked with incorporating a new requirement that entails changing an existing interface. Being new to the code, your first challenge is to figure out the exact impact that changing this interface will have on the system's clients. For the sake of this example, the implied impact is more than that of merely changing each of the clients to make the method calls per the changed signatures. Therefore, knowing all the clients of the interface may be instrumental in determining the best approach to implementing the new requirement, as well as whether the requirement is worthwhile for the given system. I'll use static analysis to first determine the clients of the interface, and then evaluate the impact of interface change on the system.
This technique is based on aspects signaling compile-time warnings if specific join points are found to be reachable. In this case, you will write join points to capture calls to all methods of the specific interface. You must first extract the application code base in a local area, alter the build file to use the AspectJ compiler and to include the aspect classes at the appropriate places, and then run a complete build of the system. If you have captured the join points correctly you can expect to see compile-time warnings to signal calls to the methods of interest in the codebase.
The compile-time aspect shown in Listing 1 detects and displays
all classes that call (an implementor of) the interface com.infosys.setl.apps.AppA.InterfaceA, while
excluding the (one or more) implementor classes themselves. So, for
the sample InterfaceA, its implementor
class ClassA, and the caller class ClassB, the aspect will generate a compile-time
warning for the call a.methodA() in ClassB.
Listing 1. Classes that use InterfaceA
package com.infosys.setl.apps.AppA;
public interface InterfaceA
{
public String methodA();
public int methodB();
public void methodC();
}
package com.infosys.setl.apps.AppA;
public class ClassA implements InterfaceA
{
public String methodA()
{
return "Hello, World!";
}
public int methodB()
{
return 1;
}
public void methodC()
{
System.out.println("Hello, World!");
}
}
package com.infosys.setl.apps.AppB;
import com.infosys.setl.apps.AppA.*;
public class ClassB
{
public static void main(String[] args)
{
try
{
InterfaceA a =
(InterfaceA)Class.forName(
"com.infosys.setl.apps.AppA.ClassA").newInstance();
System.out.println(a.methodA());
}
catch (Exception e)
{
e.printStackTrace();
}
}
}
package com.infosys.setl.aspects;
public aspect InterfaceCallersAspect
{
pointcut callerMethodA(): call
(* com.infosys.setl.apps.AppA.InterfaceA.methodA(..)) &&
!within(com.infosys.setl.apps.AppA.InterfaceA+);
pointcut callerMethodB(): call
(* com.infosys.setl.apps.AppA.InterfaceA.methodB(..)) &&
!within(com.infosys.setl.apps.AppA.InterfaceA+);
pointcut callerMethodC(): call
(* com.infosys.setl.apps.AppA.InterfaceA.methodC(..)) &&
!within(com.infosys.setl.apps.AppA.InterfaceA+);
declare warning: callerMethodA(): "Call to InterfaceA.methodA";
declare warning: callerMethodB(): "Call to InterfaceA.methodB";
declare warning: callerMethodC(): "Call to InterfaceA.methodC";
}
|
The impact of an interface change on the implementor classes can
be determined by an aspect like the one shown in Listing 2. For the
sample classes in the previous listing, this aspect generates a
compile-time warning for ClassA.
Listing 2. Classes that implement InterfaceA
package com.infosys.setl.aspects;
public aspect InterfaceImplAspect
{
pointcut impls(): staticinitialization (com.infosys.setl.apps.AppA.InterfaceA+) &&
!(within(com.infosys.setl.apps.AppA.InterfaceA));
declare warning: impls(): "InterfaceA Implementer";
}
|
A system grows piecemeal as enhancements requests trickle in. Whenever you make a change in one part of a system or application it is important to be aware of its impact in other parts. For example, refactoring the code in Module A may result in code elsewhere (either methods in a class or the entire class itself) becoming unreachable (or "dead") because the control/data flow has been updated to navigate around it. Leaving dead code unattended can eventually lead to performance issues as the system becomes weighed down with unused code.
Fortunately, the same technique used to determine the impact of code enhancement (as shown in the previous section) can be applied to the identification of dead or unreachable code. Just as the compile-time aspect shown in Listing 1 can be used to detect all methods in the interface that are being called, they can also point us to those that are not. Any methods in the interface that do not generate a compile-time warning can be presumed dead, and can thus be safely removed.
At some point you may have to maintain a legacy application that has been developed with hardcoded calls to or against specific components as opposed to interfaces. Altering such systems -- for example with the addition of new or alternative components -- can be quite intrusive, and also very challenging. Fortunately, you can use AOP to decouple specific components from the system and replace them with generic, interface-based components. In so doing, you ensure that the actual implementor can be plugged in on-the-fly.
Consider the sample class shown in Listing 3. The methodA
of ClassA has a hardcoded form of logging
in which calls are directly made to System.out.
Listing 3. Class with hard-coded logging calls
package com.infosys.setl.apps.AppA;
public class ClassA
{
public int methodA(int x)
{
System.out.println("About to double!");
x*=2;
System.out.println("Have doubled!");
return x;
}
public static void main(String args[])
{
ClassA a = new ClassA();
int ret = a.methodA(2);
}
}
|
It clearly would be intrusive to manually replace all of the
existing logging calls with calls to a generic logger interface.
A better option would be to use the aspect shown in Listing 4 to find-and-replace all
such calls. The generic logger interface would then be provided
by LoggerInterface, and a factory class LoggerFactory could be used to obtain specific
logger instances (SETLogger in Listing 4).
Listing 4. A generic interface to replace hardcoded calls
package com.infosys.setl.utils;
public interface LoggerInterface
{
public void log(String logMsg, Object target);
}
package com.infosys.setl.utils;
public class LoggerFactory
{
private static LoggerFactory _instance = null;
private LoggerFactory(){}
public static synchronized LoggerFactory getInstance()
{
if (_instance == null)
_instance = new LoggerFactory();
return _instance;
}
public LoggerInterface getLogger()
{
LoggerInterface l = null;
try
{
l = (LoggerInterface)Class.forName(
"com.infosys.setl.utils.SETLogger").newInstance();
}
catch (Exception e)
{
e.printStackTrace();
}
finally
{
return l;
}
}
}
package com.infosys.setl.utils;
public class SETLogger implements LoggerInterface
{
public void log(String logMsg, Object target)
{
System.out.println(logMsg);
}
}
package com.infosys.setl.aspects;
import com.infosys.setl.utils.*;
public aspect UnplugAspect
{
void around(String logMsg, Object source):
call (void java.io.PrintStream.println(String)) &&
within(com.infosys.setl.apps.AppA.ClassA) &&
!within(com.infosys.setl.aspects..*) && args(logMsg) && target(source)
{
logger.log(logMsg, source);
}
private LoggerInterface logger = LoggerFactory.getInstance().getLogger();
}
|
In this section I'll go over several techniques of dynamic analysis, once again applying them to familiar maintenance scenarios. The scenarios you'll resolve with dynamic analysis are as follows:
- Generating a dynamic call graph
- Evaluating the impact of exceptions on a system
- Customizing a system based on specific conditions
Recall that a dynamic AOP analysis requires us to check out the application code base into a local area, change the build file to use the AspectJ compiler and include the aspect classes at the appropriate places, build the entire system, and then run the use case(s) that the system was designed for. Assuming that you specify the join points of interest correctly, you will be able to gather a wealth of information by reflection into the running application.
Generating a dynamic call graph
A single use-case invariably traverses multiple program modules. It is not uncommon to form a mental representation of this traversal path by running through the execution sequence of the code in your mind. Obviously, remembering all this information becomes harder as the traversal path becomes longer, as is wont to happen for larger systems. In this section, you'll learn how to generate a dynamic call graph, which is a nested trace that depicts the pushing and popping of frames on the execution stack as a use-case runs from start to finish.
In Listing 5, you can see how you use a compile-time aspect to generate a call graph dynamically, following the execution of a use-case.
Listing 5. Control-flow graph generation
package com.infosys.abhi;
public class ClassA
{
public void methodA(int x)
{
x*=2;
com.infosys.abhi.ClassB b = new com.infosys.abhi.ClassB();
b.methodB(x);
}
}
package com.infosys.abhi;
public class ClassB
{
public void methodB(int x)
{
x*=2;
com.infosys.bela.ClassC c = new com.infosys.bela.ClassC();
c.methodC(x);
}
}
package com.infosys.bela;
public class ClassC
{
public void methodC(int x)
{
x*=2;
com.infosys.bela.ClassD d = new com.infosys.bela.ClassD();
d.methodD(x);
}
}
package com.infosys.bela;
public class ClassD
{
public void methodD(int x)
{
x*=2;
}
}
package com.infosys.setl.aspects;
public aspect LogStackAspect
{
private int callDepth=0;
pointcut cgraph(): !within(com.infosys.setl.aspects..*)
&& execution(* com.infosys..*.*(..));
Object around(): cgraph()
{
callDepth++;
logEntry(thisjoin point, callDepth);
Object result = proceed();
callDepth--;
logExit(thisjoin point, callDepth);
return result;
}
void logEntry(join point jp, int callDepth)
{
StringBuffer msgBuff = new StringBuffer();
while (callDepth >0)
{
msgBuff.append(" ");
callDepth--;
}
msgBuff.append("->").append(jp.toString());
log(msgBuff.toString());
}
void logExit(join point jp, int callDepth)
{
StringBuffer msgBuff = new StringBuffer();
while (callDepth >0)
{
msgBuff.append(" ");
callDepth--;
}
msgBuff.append("<-").append(jp.toString());
log(msgBuff.toString());
}
void log(String msg)
{
System.out.println(msg);
}
}
Output:
=======
->execution(void com.infosys.abhi.ClassA.methodA(int))
->execution(void com.infosys.abhi.ClassB.methodB(int))
->execution(void com.infosys.bela.ClassC.methodC(int))
->execution(void com.infosys.bela.ClassD.methodD(int))
<-execution(void com.infosys.bela.ClassD.methodD(int))
<-execution(void com.infosys.bela.ClassC.methodC(int))
<-execution(void com.infosys.abhi.ClassB.methodB(int))
<-execution(void com.infosys.abhi.ClassA.methodA(int))
|
AspectJ also provides a construct called cflowbelow that indicates the join points
(points of execution) below the control flow of each join point
picked out by a certain pointcut. Using this construct, you can ask
for the call graph to be truncated to arbitrary depths. This is
useful in such situations as where the control flow is running
through a large loop, causing the same call graph output to be
repeated over and over again (which in turn leads to an increased
graph size and consequent reduced usefulness).
Evaluating the impact of exceptions
Oftentimes, you will be called upon to debug an exception that occurred in the production environment. Such exceptions can cause the undesirable effects of (for example) dumping the stack trace on the user interface or not releasing a contentious resource such as a shared lock.
While it's important to evaluate the impact of exceptions on the overall system, it can be very hard to simulate certain exceptions in your development environment. This may be due to the nature of the exception (such as a network exception) or due to the development environment not being an exact replica of the production environment. For example, an exception that occurred in the production environment due to database corruption cannot be simulated without knowing the exact whereabouts of the corruption. It also may not be possible to capture a snapshot of the production database to transfer to the development server.
In addition to simulating the exception to figure out where the problem lies within the application, you would also like to be able to test that the code fix to ensure that it handles the problem correctly. This is problematic unless the exception can be generated in the patched code and its handling observed. In this section, you'll see how to use AOP techniques to simulate the throwing of an exception as a result of calling a method on an object, without having to recreate the exact runtime conditions that would cause the exception to be really thrown.
Consider the sample class com.infosys.setl.apps.AppA.ClassA shown in Listing 6. The call to methodA() of this class could lead to a
java.io.IOException under
certain circumstances. You're interested in knowing the
behavior of the caller of methodA() (the
class com.infosys.setl.apps.AppB.ClassB
shown in the same listing) if this exception were to happen. You don't,
however, want to spend the time and resources to set up the
conditions for an actual exception.
To resolve this, you use the pointcut genExceptionA of aspect GenException to trap the execution of methodA() and cause an java.io.IOException at runtime.
You can then test whether the application (represented here by
ClassB) is able to handle the exception as
per specification, as shown in Listing 6. (Of course, you could also
alter the advice to an "after" advice, which could execute following
the execution of methodA(), as represented
by the pointcut genExceptionAfterA.)
Note that in a real life
scenario, ClassA could be a third-party
library such as a JDBC driver.
Listing 6. Generating an exception from running code
package com.infosys.abhi;
import java.io.*;
public class ClassA
{
public void methodA() throws IOException
{
System.out.println("Hello, World!");
}
}
package com.infosys.bela;
public class ClassB
{
public static void main(String[] args)
{
try
{
com.infosys.abhi.ClassA a = new com.infosys.abhi.ClassA();
a.methodA();
com.infosys.abhi.ClassC c = new com.infosys.abhi.ClassC();
System.out.println(c.methodC());
}
catch (java.io.IOException e)
{
e.printStackTrace();
}
catch (Exception e)
{
e.printStackTrace();
}
}
}
package com.infosys.abhi;
public class ClassC
{
public String methodC()
{
System.out.println("Hello, World!");
return "Hi, World!";
}
}
package com.infosys.setl.aspects;
public aspect GenException
{
pointcut genExceptionA():
execution(public void com.infosys.abhi.ClassA.methodA() throws java.io.IOException);
pointcut genExceptionC():
call(void java.io.PrintStream.println(String)) &&
withincode(public String com.infosys.abhi.ClassC.methodC());
pointcut genExceptionAfterA():
call(public void com.infosys.abhi.ClassA.methodA() throws java.io.IOException);
void around() throws java.io.IOException : genExceptionA()
{
java.io.IOException e = new java.io.IOException();
throw e;
}
after() throws java.io.IOException : genExceptionAfterA()
{
java.io.IOException e = new java.io.IOException();
throw e;
}
after() throws java.lang.OutOfMemoryError : genExceptionC()
{
java.lang.OutOfMemoryError e = new java.lang.OutOfMemoryError();
throw e;
}
}
|
In a similar scenario, you might run across an application
throwing an unchecked exception (say, a subclass of java.lang.RuntimeException such as NullPointerException) or an error (say, a
subclass of java.lang.Error such as OutOfMemoryError). Both of these exception types
are difficult to simulate in a development environment. Instead, you
could use the pointcut genExceptionC along
with the corresponding after advice (shown above) to cause the
running code to throw an OutOfMemory error following the
call to System.out.println() within methodC() of ClassC.
Customizing a system based on conditions
Typically, AOP is thought of in the context of concerns that crosscut multiple system modules. There is, however, scope for using aspects for concerns that are concentrated to a specific part of the system only. For example, you might be required to make some very specific changes to the system, such as to keep a customer happy, or to reflect a change in local regulatory laws. Some change(s) may even be temporal (that is, they must be removed from the system after a specific time period has passed).
In such cases, you may not find it possible (or even desirable) to branch the code off to make the relevant enhancements directly in the code, due to the following reasons:
- You may end up having to manage multiple code branches
in the CVS with differing versions. This is a management headache and
increases the possibility of errors. Even assuming that all the
requested enhancements are beneficial features that do figure in
the overall product roadmap, introducing these features via aspects
first will give the development team the opportunity to "test the
water," as it were, without having to commit the change to the
CVS.
- If a temporal feature must be removed from the system, the regression testing required if the feature had been introduced directly into the code will be resource consuming and prone to mistakes. On the other hand, if the feature were to be introduced via AOP, removal would be simply a matter of recompiling the system with the aspect excluded from the build.
Fortunately, it's easy to weave in such customization to the system via aspects. The weaving can be built-in at compile time (with AspectJ) or at load-time (with AspectWerkz). When designing the aspect it's important to keep in mind that the AOP implementation may have inherent limitations with respect to context exposure (for example, AspectJ doesn’t allow local variables of a method to be exposed to the advice code via the execution context). However, generally speaking, using aspects to customize the system will lead to a clean implementation with a clear separation of the isolated concern from the baseline version of the system code.
In this article you've learned how to use aspect-oriented programming to both comprehend and unobtrusively maintain large, complex legacy systems. You've combined two techniques of analysis and the power of dynamic crosscutting to a number of common maintenance scenarios, with emphasis on troubleshooting errors and unobtrusively enhancing existing code. All of the techniques demonstrated here should prove invaluable if you are responsible for the day-to-day maintenance of a legacy application.
- You can download AspectJ from the
AspectJ Project homepage.
- Get an in-depth introduction to AOP and AspectJ with
"Improve modularity with aspect-oriented programming" (developerWorks,
January 2002).
- Learn more about the best-known flavor of Java-based AOP with
Test flexibly with AspectJ and mock objects (developerWorks,
May 2002).
- "AOP banishes the tight-coupling blues" (developerWorks,
February 2004) is a hands-on introduction to static-crosscutting techniques.
- AspectWerkz is a
lightweight, open source Java-based implementation of AOP.
- JBossAOP is another open source
contender in the Java-based AOP space.
- Rigi is an interactive, visual tool (developed by researchers in the Department of Computer Science at the University of Victoria, British Columbia, Canada) that is designed to help you better understand and re-document your software.
- Klocwork InSight can be used to extract an accurate graphical view of the design of your software directly from your existing source code (C, C++, and Java code) to provide a comprehensive understanding of your application's structure and design.
- Browse for books on these and other technical topics.
- Also see the Java technology zone tutorials page for a complete listing of
free Java-focused tutorials from developerWorks.
Abhijit Belapurkar has a bachelor of technology degree in computer science from the Indian Institute of Technology (IIT), Delhi, India. He has worked in the areas of architectures and information security for distributed applications for almost 10 years and has used the Java platform to build n-tier applications for more than five years. He presently works as a senior technical architect in the J2EE space, with Infosys Technologies Limited, Bangalore, India.
Comments (Undergoing maintenance)





